미디어위키:Common.js: 두 판 사이의 차이
외관
S3 연구 메모리 원본 스키마 동기화 |
S3 연구 메모리 원본 스키마 동기화 |
||
| 423번째 줄: | 423번째 줄: | ||
var minY = Math.min.apply(null, ys) - 38; | var minY = Math.min.apply(null, ys) - 38; | ||
var maxY = Math.max.apply(null, ys) + 38; | var maxY = Math.max.apply(null, ys) + 38; | ||
transform.scale = Math.max(0. | transform.scale = Math.max(0.2, Math.min(1.25, Math.min( | ||
size.width / Math.max(1, maxX - minX), | size.width / Math.max(1, maxX - minX), | ||
size.height / Math.max(1, maxY - minY) | size.height / Math.max(1, maxY - minY) | ||
| 527번째 줄: | 527번째 줄: | ||
var dx = second.x - first.x || 0.01; | var dx = second.x - first.x || 0.01; | ||
var dy = second.y - first.y || 0.01; | var dy = second.y - first.y || 0.01; | ||
var distanceSquared = dx * dx + dy * dy + | var distanceSquared = dx * dx + dy * dy; | ||
var force = | var distance = Math.sqrt(distanceSquared) || 0.01; | ||
var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7; | |||
var force = distance < minimumDistance | |||
? (minimumDistance - distance) * 0.035 / distance | |||
: 1.8 / Math.max(80, distanceSquared); | |||
first.vx -= dx * force; | first.vx -= dx * force; | ||
first.vy -= dy * force; | first.vy -= dy * force; | ||
2026년 7월 19일 (일) 11:15 판
(function () {
'use strict';
var root = document.getElementById('s3rm-knowledge-graph');
var semanticRoot = document.getElementById('s3rm-semantic-search');
if (!root || !window.mw) {
return;
}
var RELATIONS = [
{ property: 'Supports', key: 'supports', label: '뒷받침함', color: '#5f7ae6' },
{ property: 'Contradicts', key: 'contradicts', label: '상충함', color: '#d0605b' },
{ property: 'Analogous to', key: 'analogous_to', label: '유사함', color: '#a17bd7' },
{ property: 'Failed because', key: 'failed_because', label: '실패 원인', color: '#dc8b45' },
{ property: 'Works when', key: 'works_when', label: '작동 조건', color: '#43a27b' },
{ property: 'Suggests', key: 'suggests', label: '후속 제안', color: '#3f9db7' },
{ property: 'Supersedes', key: 'supersedes', label: '대체함', color: '#7d849e' }
];
var NODE_LIMIT = 160;
var EDGE_LIMIT = 480;
var mcpReady = null;
var graphSearchController = null;
var pendingGraphSearch = null;
function mcpRequest(payload, expectBody) {
return window.fetch('/mcp', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify(payload)
}).then(function (response) {
if (!response.ok) {
throw new Error('MCP HTTP ' + response.status);
}
if (!expectBody) {
return null;
}
return response.json();
});
}
function initializeMcp() {
if (!mcpReady) {
mcpReady = mcpRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 's3rm-wiki-search', version: '1' }
}
}, true).then(function (response) {
if (!response || response.error) {
throw new Error('MCP initialization failed');
}
return mcpRequest({
jsonrpc: '2.0',
method: 'notifications/initialized',
params: {}
}, false);
});
}
return mcpReady;
}
function searchSemantically(query) {
return initializeMcp().then(function () {
return mcpRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'search_lessons',
arguments: {
query: query,
retrieval_mode: 'hybrid_v1',
fields: ['title', 'question', 'reusable_lesson', 'confidence', 'relations'],
limit: 6
}
}
}, true);
}).then(function (response) {
if (!response || response.error || !response.result || response.result.isError) {
throw new Error('Semantic search failed');
}
return response.result.structuredContent || {};
});
}
function textValue(value) {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || '');
}
function firstValue(values, fallback) {
return values && values.length ? textValue(values[0]) : fallback;
}
function confidenceLabel(confidence) {
if (confidence === 'high') { return '신뢰도: 높음'; }
if (confidence === 'medium') { return '신뢰도: 중간'; }
return '신뢰도: 낮음';
}
function parseGraph(data) {
var rawResults = data && data.query && data.query.results ? data.query.results : {};
var entries = Array.isArray(rawResults)
? rawResults.map(function (item) { return [item.fulltext, item]; })
: Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });
var nodes = [];
var byId = Object.create(null);
entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {
var id = entry[0];
var result = entry[1] || {};
var printouts = result.printouts || {};
var angle = index * 2.399963229728653;
var radius = 20 * Math.sqrt(index + 1);
var node = {
id: id,
title: firstValue(printouts['Lesson title'], id.replace(/^Lesson:/, '')),
confidence: firstValue(printouts.Confidence, 'low').toLowerCase(),
updatedAt: firstValue(printouts['Updated at'], ''),
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
vx: 0,
vy: 0,
degree: 0,
printouts: printouts
};
nodes.push(node);
byId[id] = node;
});
var edges = [];
nodes.forEach(function (node) {
RELATIONS.forEach(function (relation) {
var targets = node.printouts[relation.property] || [];
targets.forEach(function (target) {
var targetId = textValue(target);
if (edges.length < EDGE_LIMIT && byId[targetId] && targetId !== node.id) {
edges.push({ source: node, target: byId[targetId], relation: relation });
node.degree += 1;
byId[targetId].degree += 1;
}
});
});
delete node.printouts;
});
var connectedNodes = nodes.filter(function (node) { return node.degree > 0; });
var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });
connectedNodes.forEach(function (node, index) {
var angle = index * 2.399963229728653;
var radius = 16 * Math.sqrt(index + 1);
node.x = Math.cos(angle) * radius;
node.y = Math.sin(angle) * radius;
});
isolatedNodes.forEach(function (node, index) {
var angle = index * 2.399963229728653 + 0.7;
var radius = 92 + 10 * Math.sqrt(index + 1);
node.x = Math.cos(angle) * radius;
node.y = Math.sin(angle) * radius;
});
return { nodes: nodes, edges: edges };
}
function makeElement(tag, className, text) {
var element = document.createElement(tag);
if (className) {
element.className = className;
}
if (text !== undefined) {
element.textContent = text;
}
return element;
}
function createSemanticSearch() {
if (!semanticRoot) {
return;
}
semanticRoot.replaceChildren();
var intro = makeElement('div', 's3rm-semantic-intro');
intro.appendChild(makeElement('div', 's3rm-semantic-icon', '⌕'));
var introCopy = makeElement('div', '');
introCopy.appendChild(makeElement('strong', '', '검색'));
introCopy.appendChild(makeElement('p', '', '현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.'));
intro.appendChild(introCopy);
var form = makeElement('form', 's3rm-semantic-form');
var input = makeElement('input', 's3rm-semantic-input');
input.type = 'search';
input.name = 'query';
input.maxLength = 500;
input.placeholder = '예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?';
input.setAttribute('aria-label', '메모 검색');
var submit = makeElement('button', 's3rm-semantic-submit', '검색');
submit.type = 'submit';
form.append(input, submit);
var examples = makeElement('div', 's3rm-semantic-examples');
examples.appendChild(makeElement('span', '', '검색 예시'));
[
'재시도가 한꺼번에 몰리는 이유',
'GPU와 SSD 사이 병목을 줄이는 방법',
'긴 작업에서 지연이 갑자기 커지는 원인'
].forEach(function (example) {
var button = makeElement('button', '', example);
button.type = 'button';
button.addEventListener('click', function () {
input.value = example;
form.requestSubmit();
});
examples.appendChild(button);
});
var feedback = makeElement('div', 's3rm-semantic-feedback');
feedback.setAttribute('aria-live', 'polite');
feedback.hidden = true;
semanticRoot.append(intro, form, examples, feedback);
function renderSearchResults(payload, query) {
var items = Array.isArray(payload.items) ? payload.items : [];
if (graphSearchController) {
graphSearchController(items, query);
} else {
pendingGraphSearch = { items: items, query: query };
}
feedback.replaceChildren();
feedback.hidden = false;
var resultHeader = makeElement('div', 's3rm-semantic-result-header');
resultHeader.appendChild(makeElement('strong', '', '“' + query + '” 검색 결과'));
resultHeader.appendChild(makeElement('span', 's3rm-semantic-mode', items.length + '개 메모'));
feedback.appendChild(resultHeader);
if (!items.length) {
feedback.appendChild(makeElement('p', 's3rm-semantic-empty', '가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.'));
return;
}
feedback.appendChild(makeElement('p', 's3rm-semantic-graph-note', '아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.'));
var list = makeElement('div', 's3rm-semantic-result-links');
items.forEach(function (item) {
var link = makeElement('a', '', item.title || String(item.id).replace(/^Lesson:/, ''));
link.href = mw.util.getUrl(item.id);
list.appendChild(link);
});
feedback.appendChild(list);
}
form.addEventListener('submit', function (event) {
event.preventDefault();
var query = input.value.trim();
if (query.length < 2) {
feedback.replaceChildren(makeElement('p', 's3rm-semantic-error', '두 글자 이상 입력해 주세요.'));
feedback.hidden = false;
input.focus();
return;
}
submit.disabled = true;
submit.textContent = '찾는 중…';
feedback.replaceChildren(makeElement('div', 's3rm-semantic-searching', '관련 메모를 찾고 있습니다.'));
feedback.hidden = false;
searchSemantically(query).then(function (payload) {
renderSearchResults(payload, query);
}).catch(function () {
feedback.replaceChildren();
var error = makeElement('div', 's3rm-semantic-error');
error.appendChild(makeElement('strong', '', '지금은 검색을 사용할 수 없습니다.'));
var fallback = makeElement('a', '', '검색 열기');
fallback.href = mw.util.getUrl('Special:Search', { search: query });
error.appendChild(fallback);
feedback.appendChild(error);
}).finally(function () {
submit.disabled = false;
submit.textContent = '검색';
});
});
}
function renderEmpty() {
root.replaceChildren();
var empty = makeElement('div', 's3rm-graph-empty');
empty.appendChild(makeElement('strong', '', '아직 연결할 메모가 없습니다'));
empty.appendChild(makeElement('p', '', '첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.'));
var link = makeElement('a', 's3rm-graph-primary-link', '첫 메모 작성하기');
link.href = '#quick-start';
empty.appendChild(link);
root.appendChild(empty);
}
function renderError() {
root.replaceChildren();
var fallback = makeElement('div', 's3rm-graph-empty');
fallback.appendChild(makeElement('strong', '', '그래프를 불러오지 못했습니다'));
fallback.appendChild(makeElement('p', '', '최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.'));
var link = makeElement('a', 's3rm-graph-primary-link', '전체 메모 보기');
link.href = mw.util.getUrl('Special:AllPages/Lesson:');
fallback.appendChild(link);
root.appendChild(fallback);
}
function createGraph(graph) {
var nodes = graph.nodes;
var edges = graph.edges;
var byId = Object.create(null);
function canonicalId(id) {
return String(id || '').replace(/_/g, ' ').toLocaleLowerCase('en');
}
nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });
var selected = null;
var hovered = null;
var searchCluster = null;
var activeRelation = 'all';
var transform = { x: 0, y: 0, scale: 1 };
var dragging = null;
var panning = false;
var pointerStart = null;
var animationFrame = 0;
var simulationTicks = 0;
root.replaceChildren();
var toolbar = makeElement('div', 's3rm-graph-toolbar');
var searchWrap = makeElement('div', 's3rm-graph-search');
var search = makeElement('input', 's3rm-graph-search-input');
search.type = 'search';
search.placeholder = '그래프에서 제목 필터';
search.setAttribute('aria-label', '그래프 제목 필터');
search.autocomplete = 'off';
var searchResults = makeElement('div', 's3rm-graph-search-results');
searchResults.hidden = true;
searchWrap.append(search, searchResults);
var filter = makeElement('select', 's3rm-graph-filter');
filter.setAttribute('aria-label', '관계 유형 필터');
var allOption = makeElement('option', '', '모든 관계');
allOption.value = 'all';
filter.appendChild(allOption);
RELATIONS.forEach(function (relation) {
var option = makeElement('option', '', relation.label);
option.value = relation.key;
filter.appendChild(option);
});
var reset = makeElement('button', 's3rm-graph-reset', '화면 맞춤');
reset.type = 'button';
var clearSearch = makeElement('button', 's3rm-graph-clear-search', '검색 결과 해제');
clearSearch.type = 'button';
clearSearch.hidden = true;
toolbar.append(searchWrap, filter, reset, clearSearch);
var body = makeElement('div', 's3rm-graph-body');
var viewport = makeElement('div', 's3rm-graph-viewport');
var canvas = document.createElement('canvas');
canvas.className = 's3rm-graph-canvas';
canvas.setAttribute('aria-hidden', 'true');
viewport.appendChild(canvas);
var hint = makeElement('div', 's3rm-graph-hint', '드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기');
viewport.appendChild(hint);
var inspector = makeElement('aside', 's3rm-graph-inspector');
inspector.setAttribute('aria-live', 'polite');
body.append(viewport, inspector);
var footer = makeElement('div', 's3rm-graph-footer');
var counts = makeElement('span', 's3rm-graph-counts', nodes.length + '개 메모 · ' + edges.length + '개 연결');
var legend = makeElement('div', 's3rm-graph-legend');
RELATIONS.slice(0, 4).forEach(function (relation) {
var item = makeElement('span', 's3rm-graph-legend-item');
var swatch = makeElement('i', '');
swatch.style.backgroundColor = relation.color;
item.append(swatch, document.createTextNode(relation.label));
legend.appendChild(item);
});
footer.append(counts, legend);
root.append(toolbar, body, footer);
var context = canvas.getContext('2d');
var size = { width: 0, height: 0, ratio: 1 };
function visibleEdges() {
return activeRelation === 'all'
? edges
: edges.filter(function (edge) { return edge.relation.key === activeRelation; });
}
function nodeRadius(node) {
return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);
}
function confidenceColor(confidence) {
if (confidence === 'high') { return '#53c18b'; }
if (confidence === 'medium') { return '#8ca8ff'; }
return '#c0c5d8';
}
function resize() {
var rect = viewport.getBoundingClientRect();
size.width = Math.max(320, rect.width);
size.height = Math.max(360, rect.height);
size.ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(size.width * size.ratio);
canvas.height = Math.round(size.height * size.ratio);
canvas.style.width = size.width + 'px';
canvas.style.height = size.height + 'px';
draw();
}
function fitGraph() {
if (!nodes.length) { return; }
var xs = nodes.map(function (node) { return node.x; });
var ys = nodes.map(function (node) { return node.y; });
var minX = Math.min.apply(null, xs) - 38;
var maxX = Math.max.apply(null, xs) + 38;
var minY = Math.min.apply(null, ys) - 38;
var maxY = Math.max.apply(null, ys) + 38;
transform.scale = Math.max(0.2, Math.min(1.25, Math.min(
size.width / Math.max(1, maxX - minX),
size.height / Math.max(1, maxY - minY)
)));
transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;
transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;
draw();
}
function draw() {
context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);
context.clearRect(0, 0, size.width, size.height);
context.save();
context.translate(transform.x, transform.y);
context.scale(transform.scale, transform.scale);
if (searchCluster) {
context.beginPath();
context.arc(
searchCluster.x,
searchCluster.y,
searchCluster.radius + 48,
0,
Math.PI * 2
);
context.fillStyle = 'rgba(104, 126, 233, 0.12)';
context.fill();
context.setLineDash([7 / transform.scale, 6 / transform.scale]);
context.lineWidth = 1.5 / transform.scale;
context.strokeStyle = 'rgba(140, 168, 255, 0.75)';
context.stroke();
context.setLineDash([]);
context.font = '700 ' + (12 / transform.scale) + 'px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'bottom';
context.fillStyle = '#b9c7ff';
context.fillText(
'검색 결과 · ' + searchCluster.nodes.length + '개 메모',
searchCluster.x,
searchCluster.y - searchCluster.radius - 58 / transform.scale
);
}
visibleEdges().forEach(function (edge) {
var emphasized = selected && (edge.source === selected || edge.target === selected);
var searchEdge = searchCluster &&
(edge.source.isSearchResult || edge.target.isSearchResult);
context.globalAlpha = searchCluster && !searchEdge ? 0.1 : 1;
context.beginPath();
context.moveTo(edge.source.x, edge.source.y);
context.lineTo(edge.target.x, edge.target.y);
context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + '55';
context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;
context.stroke();
});
context.globalAlpha = 1;
nodes.forEach(function (node) {
var isSelected = node === selected;
var isHovered = node === hovered;
var isSearchResult = Boolean(node.isSearchResult);
var radius = nodeRadius(node);
context.globalAlpha = searchCluster && !isSearchResult ? 0.12 : 1;
context.beginPath();
context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);
context.fillStyle = isSelected || isSearchResult ? '#ffffff' : confidenceColor(node.confidence);
context.fill();
context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;
context.strokeStyle = isSelected || isSearchResult ? '#8ca8ff' : '#11182e';
context.stroke();
if (isSelected || isHovered || isSearchResult ||
(transform.scale > 0.68 && node.degree > 0)) {
context.font = (isSelected || isSearchResult ? '600 ' : '') + (12 / transform.scale) + 'px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'top';
context.fillStyle = '#f5f7ff';
context.shadowColor = '#080b16';
context.shadowBlur = 5 / transform.scale;
var label = node.title.length > 26 ? node.title.slice(0, 25) + '…' : node.title;
context.fillText(label, node.x, node.y + radius + 7 / transform.scale);
context.shadowBlur = 0;
}
});
context.globalAlpha = 1;
context.restore();
}
function simulate() {
if (simulationTicks > 220) {
if (searchCluster) {
draw();
} else {
fitGraph();
}
return;
}
for (var pass = 0; pass < 3; pass += 1) {
for (var i = 0; i < nodes.length; i += 1) {
for (var j = i + 1; j < nodes.length; j += 1) {
var first = nodes[i];
var second = nodes[j];
var dx = second.x - first.x || 0.01;
var dy = second.y - first.y || 0.01;
var distanceSquared = dx * dx + dy * dy;
var distance = Math.sqrt(distanceSquared) || 0.01;
var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;
var force = distance < minimumDistance
? (minimumDistance - distance) * 0.035 / distance
: 1.8 / Math.max(80, distanceSquared);
first.vx -= dx * force;
first.vy -= dy * force;
second.vx += dx * force;
second.vy += dy * force;
}
}
edges.forEach(function (edge) {
var dx = edge.target.x - edge.source.x;
var dy = edge.target.y - edge.source.y;
var distance = Math.sqrt(dx * dx + dy * dy) || 1;
var force = (distance - 64) * 0.0014;
edge.source.vx += dx * force;
edge.source.vy += dy * force;
edge.target.vx -= dx * force;
edge.target.vy -= dy * force;
});
nodes.forEach(function (node) {
if (node.isSearchResult) {
node.vx = 0;
node.vy = 0;
return;
}
node.vx += -node.x * 0.0018;
node.vy += -node.y * 0.0018;
node.vx *= 0.82;
node.vy *= 0.82;
node.x += node.vx;
node.y += node.vy;
});
simulationTicks += 1;
}
draw();
animationFrame = window.requestAnimationFrame(simulate);
}
function relationDefinition(key) {
return RELATIONS.find(function (relation) { return relation.key === key; });
}
function updateCounts() {
if (searchCluster) {
var relatedEdges = edges.filter(function (edge) {
return edge.source.isSearchResult || edge.target.isSearchResult;
}).length;
counts.textContent = '검색 결과 ' + searchCluster.nodes.length + '개 · ' +
relatedEdges + '개 연결';
} else {
counts.textContent = nodes.length + '개 메모 · ' + visibleEdges().length + '개 연결';
}
}
function resetSearchCluster() {
var addedKeys = Object.create(null);
edges.forEach(function (edge) {
if (edge.searchAdded) {
edge.source.degree = Math.max(0, edge.source.degree - 1);
edge.target.degree = Math.max(0, edge.target.degree - 1);
}
});
nodes.forEach(function (node) {
if (node.searchAdded) {
addedKeys[canonicalId(node.id)] = true;
delete byId[canonicalId(node.id)];
} else if (node.preSearchPosition) {
node.x = node.preSearchPosition.x;
node.y = node.preSearchPosition.y;
}
delete node.isSearchResult;
delete node.preSearchPosition;
});
nodes = nodes.filter(function (node) { return !node.searchAdded; });
edges = edges.filter(function (edge) {
return !edge.searchAdded &&
!addedKeys[canonicalId(edge.source.id)] &&
!addedKeys[canonicalId(edge.target.id)];
});
searchCluster = null;
clearSearch.hidden = true;
selected = null;
showInspector(null);
updateCounts();
}
function focusSearchResults(items, query) {
resetSearchCluster();
if (!items.length) {
fitGraph();
return;
}
var resultNodes = [];
items.forEach(function (item, index) {
var key = canonicalId(item.id);
var node = byId[key];
if (!node) {
node = {
id: item.id,
title: item.title || String(item.id).replace(/^Lesson:/, ''),
confidence: item.confidence || 'low',
updatedAt: '',
x: 0,
y: 0,
vx: 0,
vy: 0,
degree: 0,
searchAdded: true
};
nodes.push(node);
byId[key] = node;
} else {
node.preSearchPosition = { x: node.x, y: node.y };
node.title = item.title || node.title;
node.confidence = item.confidence || node.confidence;
}
node.isSearchResult = true;
node.searchRank = index + 1;
resultNodes.push(node);
});
items.forEach(function (item) {
(Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {
var source = byId[canonicalId(relationItem.from || item.id)];
var target = byId[canonicalId(relationItem.to)];
var relation = relationDefinition(relationItem.relation);
if (!source || !target || !relation || source === target) {
return;
}
var duplicate = edges.some(function (edge) {
return canonicalId(edge.source.id) === canonicalId(source.id) &&
canonicalId(edge.target.id) === canonicalId(target.id) &&
edge.relation.key === relation.key;
});
if (!duplicate) {
edges.push({
source: source,
target: target,
relation: relation,
searchAdded: true
});
source.degree += 1;
target.degree += 1;
}
});
});
var clusterRadius = resultNodes.length === 1 ? 0 :
Math.max(82, Math.min(132, resultNodes.length * 22));
resultNodes.forEach(function (node, index) {
var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);
node.x = Math.cos(angle) * clusterRadius;
node.y = Math.sin(angle) * clusterRadius;
node.vx = 0;
node.vy = 0;
});
searchCluster = {
query: query,
nodes: resultNodes,
x: 0,
y: 0,
radius: clusterRadius
};
activeRelation = 'all';
filter.value = 'all';
clearSearch.hidden = false;
transform.scale = Math.max(0.72, Math.min(
1.35,
Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)
));
transform.x = size.width / 2;
transform.y = size.height / 2;
selectNode(resultNodes[0]);
updateCounts();
draw();
viewport.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function connectionSummary(node) {
var connected = edges.filter(function (edge) {
return edge.source === node || edge.target === node;
});
var summary = Object.create(null);
connected.forEach(function (edge) {
summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;
});
return { edges: connected, counts: summary };
}
function showInspector(node) {
inspector.replaceChildren();
if (!node) {
var welcome = makeElement('div', 's3rm-inspector-welcome');
welcome.appendChild(makeElement('span', 's3rm-inspector-symbol', '⌁'));
welcome.appendChild(makeElement('strong', '', '메모를 선택하세요'));
welcome.appendChild(makeElement('p', '', '점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.'));
inspector.appendChild(welcome);
return;
}
var summary = connectionSummary(node);
inspector.appendChild(makeElement(
'div',
's3rm-inspector-kicker',
node.isSearchResult ? '검색 결과 ' + node.searchRank : '선택한 메모'
));
inspector.appendChild(makeElement('h3', '', node.title));
var meta = makeElement('div', 's3rm-inspector-meta');
meta.appendChild(makeElement('span', 's3rm-confidence s3rm-confidence-' + node.confidence, confidenceLabel(node.confidence)));
meta.appendChild(makeElement('span', '', summary.edges.length + '개 연결'));
inspector.appendChild(meta);
if (Object.keys(summary.counts).length) {
var relationList = makeElement('ul', 's3rm-inspector-relations');
Object.keys(summary.counts).forEach(function (label) {
var item = makeElement('li', '');
item.append(makeElement('span', '', label), makeElement('strong', '', String(summary.counts[label])));
relationList.appendChild(item);
});
inspector.appendChild(relationList);
} else {
inspector.appendChild(makeElement('p', 's3rm-inspector-muted', '아직 다른 메모와 연결되지 않았습니다.'));
}
var open = makeElement('a', 's3rm-graph-primary-link', '메모 열기');
open.href = mw.util.getUrl(node.id);
inspector.appendChild(open);
}
function canvasPoint(event) {
var rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function worldPoint(event) {
var point = canvasPoint(event);
return {
x: (point.x - transform.x) / transform.scale,
y: (point.y - transform.y) / transform.scale
};
}
function hitNode(event) {
var point = worldPoint(event);
for (var i = nodes.length - 1; i >= 0; i -= 1) {
var node = nodes[i];
var dx = point.x - node.x;
var dy = point.y - node.y;
if (dx * dx + dy * dy <= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {
return node;
}
}
return null;
}
function selectNode(node) {
selected = node;
showInspector(node);
draw();
}
canvas.addEventListener('pointerdown', function (event) {
dragging = hitNode(event);
panning = !dragging;
pointerStart = canvasPoint(event);
canvas.setPointerCapture(event.pointerId);
canvas.classList.add('is-dragging');
});
canvas.addEventListener('pointermove', function (event) {
if (dragging) {
var world = worldPoint(event);
dragging.x = world.x;
dragging.y = world.y;
dragging.vx = 0;
dragging.vy = 0;
} else if (panning && pointerStart) {
var point = canvasPoint(event);
transform.x += point.x - pointerStart.x;
transform.y += point.y - pointerStart.y;
pointerStart = point;
} else {
hovered = hitNode(event);
canvas.style.cursor = hovered ? 'pointer' : 'grab';
}
draw();
});
canvas.addEventListener('pointerup', function (event) {
var hit = hitNode(event);
if (dragging && hit === dragging) {
selectNode(dragging);
}
dragging = null;
panning = false;
pointerStart = null;
canvas.classList.remove('is-dragging');
});
canvas.addEventListener('dblclick', function (event) {
var node = hitNode(event);
if (node) {
window.location.href = mw.util.getUrl(node.id);
}
});
canvas.addEventListener('wheel', function (event) {
event.preventDefault();
var point = canvasPoint(event);
var oldScale = transform.scale;
var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));
transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;
transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;
transform.scale = nextScale;
draw();
}, { passive: false });
search.addEventListener('input', function () {
var query = search.value.trim().toLocaleLowerCase('ko');
searchResults.replaceChildren();
if (!query) {
searchResults.hidden = true;
return;
}
var matches = nodes.filter(function (node) {
return node.title.toLocaleLowerCase('ko').includes(query) || node.id.toLowerCase().includes(query);
}).slice(0, 6);
matches.forEach(function (node) {
var button = makeElement('button', '', node.title);
button.type = 'button';
button.addEventListener('click', function () {
selectNode(node);
transform.x = size.width / 2 - node.x * transform.scale;
transform.y = size.height / 2 - node.y * transform.scale;
search.value = node.title;
searchResults.hidden = true;
draw();
});
searchResults.appendChild(button);
});
if (!matches.length) {
searchResults.appendChild(makeElement('span', '', '일치하는 메모가 없습니다.'));
}
searchResults.hidden = false;
});
search.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
searchResults.hidden = true;
} else if (event.key === 'Enter') {
var first = searchResults.querySelector('button');
if (first) { first.click(); }
}
});
filter.addEventListener('change', function () {
activeRelation = filter.value;
updateCounts();
draw();
});
reset.addEventListener('click', fitGraph);
clearSearch.addEventListener('click', function () {
resetSearchCluster();
fitGraph();
});
showInspector(null);
resize();
var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;
if (resizeObserver) {
resizeObserver.observe(viewport);
} else {
window.addEventListener('resize', resize);
}
graphSearchController = focusSearchResults;
if (pendingGraphSearch) {
focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);
pendingGraphSearch = null;
}
animationFrame = window.requestAnimationFrame(simulate);
window.addEventListener('pagehide', function () {
window.cancelAnimationFrame(animationFrame);
if (resizeObserver) { resizeObserver.disconnect(); }
}, { once: true });
}
var askQuery = '[[Category:Lessons]]' +
'|?Lesson title|?Confidence|?Updated at' +
RELATIONS.map(function (relation) { return '|?' + relation.property; }).join('') +
'|sort=Updated at|order=descending|limit=' + NODE_LIMIT;
createSemanticSearch();
mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
return new mw.Api().get({ action: 'ask', query: askQuery, formatversion: 2 });
}).then(function (data) {
var graph = parseGraph(data);
if (!graph.nodes.length) {
renderEmpty();
return;
}
createGraph(graph);
}).catch(renderError);
}());