본문으로 이동

미디어위키:Common.js: 두 판 사이의 차이

S3 연구 메모리
검색 준비 중 자동 재시도 보강
태그: 되돌려진 기여
S3 연구 메모리 원본 스키마 동기화
태그: 수동 되돌리기 되돌려진 기여
19번째 줄: 19번째 줄:
   var NODE_LIMIT = 160;
   var NODE_LIMIT = 160;
   var EDGE_LIMIT = 480;
   var EDGE_LIMIT = 480;
   var SEMANTIC_RETRY_LIMIT = 45;
   var SEMANTIC_RETRY_LIMIT = 15;
   var mcpReady = null;
   var mcpReady = null;
   var graphSearchController = null;
   var graphSearchController = null;

2026년 7월 20일 (월) 03:55 판

(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 SEMANTIC_RETRY_LIMIT = 15;
  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, attempt, onWaiting) {
    var retryCount = attempt || 0;
    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');
      }
      var payload = response.result.structuredContent || {};
      if (payload.semantic_status === 'building' && retryCount < SEMANTIC_RETRY_LIMIT) {
        if (onWaiting) {
          onWaiting();
        }
        return new Promise(function (resolve) {
          window.setTimeout(resolve, 2000);
        }).then(function () {
          return searchSemantically(query, retryCount + 1, onWaiting);
        });
      }
      return payload;
    });
  }

  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;
    });
    nodes.forEach(function (node) {
      node.homeX = node.x;
      node.homeY = node.y;
    });
    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) {
        var emptyMessage = payload.semantic_status === 'building'
          ? '검색을 준비하고 있습니다. 잠시 후 다시 검색해 주세요.'
          : '가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.';
        feedback.appendChild(makeElement('p', 's3rm-semantic-empty', emptyMessage));
        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, 0, function () {
        feedback.replaceChildren(makeElement('div', 's3rm-semantic-searching', '검색을 준비하고 있습니다. 완료되면 결과를 바로 보여 드립니다.'));
      }).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 dragMotion = null;
    var animationFrame = 0;
    var simulationFrames = 0;
    var simulationRunning = false;
    var fitAfterSimulation = true;
    var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    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 startSimulation(frames, shouldFit) {
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);
      if (!simulationRunning) {
        simulationRunning = true;
        animationFrame = window.requestAnimationFrame(simulate);
      }
    }

    function simulate() {
      if (simulationFrames <= 0) {
        simulationRunning = false;
        if (fitAfterSimulation && !searchCluster) {
          fitGraph();
        } else {
          draw();
        }
        fitAfterSimulation = false;
        return;
      }

      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 isolatedPair = first.degree === 0 && second.degree === 0;
          if (isolatedPair && !first.motionActive && !second.motionActive &&
            first !== dragging && second !== dragging) {
            continue;
          }
          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);
          if (first !== dragging) {
            first.vx -= dx * force;
            first.vy -= dy * force;
          }
          if (second !== dragging) {
            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;
        if (edge.source !== dragging) {
          edge.source.vx += dx * force;
          edge.source.vy += dy * force;
        }
        if (edge.target !== dragging) {
          edge.target.vx -= dx * force;
          edge.target.vy -= dy * force;
        }
      });
      nodes.forEach(function (node) {
        if (node === dragging) {
          return;
        }
        if (node.isSearchResult && !node.motionActive) {
          node.vx = 0;
          node.vy = 0;
          return;
        }
        if (node.degree === 0 && !node.motionActive) {
          node.vx = 0;
          node.vy = 0;
          return;
        }
        if (node.degree === 0) {
          node.vx += (node.homeX - node.x) * 0.0022;
          node.vy += (node.homeY - node.y) * 0.0022;
        } else {
          node.vx += -node.x * 0.0018;
          node.vy += -node.y * 0.0018;
        }
        node.vx *= 0.9;
        node.vy *= 0.9;
        node.x += node.vx;
        node.y += node.vy;
        if (node.motionActive && Math.abs(node.vx) + Math.abs(node.vy) < 0.025) {
          delete node.motionActive;
        }
      });
      simulationFrames -= 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;
          node.homeX = node.preSearchHome.x;
          node.homeY = node.preSearchHome.y;
        }
        delete node.isSearchResult;
        delete node.preSearchPosition;
        delete node.preSearchHome;
        delete node.motionActive;
      });
      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,
            homeX: 0,
            homeY: 0,
            searchAdded: true
          };
          nodes.push(node);
          byId[key] = node;
        } else {
          node.preSearchPosition = { x: node.x, y: node.y };
          node.preSearchHome = { x: node.homeX, y: node.homeY };
          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.homeX = node.x;
        node.homeY = node.y;
        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);
      if (dragging) {
        var world = worldPoint(event);
        dragMotion = {
          x: world.x,
          y: world.y,
          time: event.timeStamp,
          vx: 0,
          vy: 0
        };
        dragging.motionActive = true;
        startSimulation(120, false);
      }
      canvas.setPointerCapture(event.pointerId);
      canvas.classList.add('is-dragging');
    });
    canvas.addEventListener('pointermove', function (event) {
      if (dragging) {
        var world = worldPoint(event);
        if (dragMotion) {
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);
          var frameRatio = 16.67 / elapsed;
          var instantVX = (world.x - dragMotion.x) * frameRatio;
          var instantVY = (world.y - dragMotion.y) * frameRatio;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);
          if (speed > 14) {
            dragMotion.vx = dragMotion.vx / speed * 14;
            dragMotion.vy = dragMotion.vy / speed * 14;
          }
          dragMotion.x = world.x;
          dragMotion.y = world.y;
          dragMotion.time = event.timeStamp;
        }
        dragging.x = world.x;
        dragging.y = world.y;
        startSimulation(120, false);
      } 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);
      var releasedNode = dragging;
      if (releasedNode && dragMotion) {
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;
      }
      dragging = null;
      panning = false;
      pointerStart = null;
      dragMotion = null;
      canvas.classList.remove('is-dragging');
      if (releasedNode) {
        if (hit === releasedNode) {
          selectNode(releasedNode);
        }
        startSimulation(140, false);
      }
    });
    canvas.addEventListener('pointercancel', function () {
      if (dragging) {
        dragging.vx = 0;
        dragging.vy = 0;
        delete dragging.motionActive;
      }
      dragging = null;
      panning = false;
      pointerStart = null;
      dragMotion = 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;
    }
    startSimulation(160, true);
    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);
}());