← Files Code Ontology CompanionARCHIVED FILE

skills/manage-code-ontology/assets/workbench.js

121 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

(function () {
  "use strict";

  const HARD_MAX_VISIBLE_NODES = 250;
  const MAX_3D_VISIBLE_NODES = 160;
  const MAX_3D_VISIBLE_EDGES = 480;
  const THREE_D_FRAME_BUDGET_MS = 20;
  const THREE_D_FRAME_INTERVAL_MS = 33;
  const THREE_D_SLOW_FRAME_INTERVAL_MS = 66;
  const MAX_SEARCH_RESULTS = 80;
  const MAX_MODULE_GROUPS = 12;
  const MAX_DETAIL_NEIGHBORS = 18;
  const STRUCTURAL_TYPES = new Set([
    "Package",
    "Module",
    "Class",
    "Interface",
    "Enum",
    "Record",
    "ExternalType",
    "ExternalModule",
  ]);
  const SPRING_GROUPS = new Set([
    "SpringBean",
    "DependencyInjection",
    "AspectOrAdvice",
    "ProxyOrInterceptor",
  ]);

  const TYPE_LABELS = {
    Package: "Java 패키지",
    Module: "Python 모듈",
    Class: "클래스",
    Interface: "인터페이스",
    Enum: "열거형",
    Record: "레코드",
    Function: "함수",
    AsyncFunction: "비동기 함수",
    Method: "메서드",
    AsyncMethod: "비동기 메서드",
    FrameworkAnnotation: "프레임워크 애너테이션",
    Decorator: "데코레이터",
    ExternalType: "외부 타입",
    ExternalModule: "외부 모듈",
    ExternalCallable: "외부 호출 대상",
    FrameworkConcept: "프레임워크 개념",
    PipelineRole: "파이프라인 역할",
    PolicyLeaf: "정책 값",
    RuntimeBranch: "런타임 분기",
  };

  const RELATION_LABELS = {
    DECLARES: "선언함",
    IMPORTS: "가져옴",
    EXTENDS: "상속함",
    IMPLEMENTS: "구현함",
    ANNOTATED_BY: "애너테이션 적용",
    DECORATED_BY: "데코레이터 적용",
    INJECTS: "의존성 주입",
    DECLARES_BEAN: "Bean 선언",
    MANAGED_AS: "프레임워크가 관리",
    MAY_BE_PROXIED_BY: "프록시 적용 가능",
    CALLS: "호출함",
    HAS_PIPELINE_ROLE: "파이프라인 역할",
    READS_POLICY_LEAF: "정책 값을 읽음",
    DECLARES_RUNTIME_BRANCH: "조건 분기 선언",
    GUARDS_RUNTIME_BRANCH: "분기를 제어함",
  };

  const RELATION_SETS = {
    explore: null,
    architecture: null,
    impact: new Set(["CALLS", "INJECTS", "EXTENDS", "IMPLEMENTS", "DECLARES_BEAN", "READS_POLICY_LEAF", "GUARDS_RUNTIME_BRANCH"]),
    spring: new Set([
      "ANNOTATED_BY",
      "INJECTS",
      "DECLARES_BEAN",
      "MANAGED_AS",
      "MAY_BE_PROXIED_BY",
    ]),
    policy: new Set([
      "READS_POLICY_LEAF",
      "DECLARES_RUNTIME_BRANCH",
      "GUARDS_RUNTIME_BRANCH",
    ]),
    pipeline: new Set(["DECORATED_BY", "CALLS", "HAS_PIPELINE_ROLE"]),
  };

  const LENS_COPY = {
    architecture: { eyebrow: "SOURCE ATLAS", title: "구조", description: "" },
    impact: { eyebrow: "DEPENDENCY TRACE", title: "영향", description: "정적 의존 관계" },
    changes: { eyebrow: "SNAPSHOT DIFF", title: "변경", description: "" },
  };
  const LENS_ALIASES = { overview: "architecture", explore: "architecture", spring: "architecture", policy: "architecture", pipeline: "architecture" };
  const GENERIC_CONCEPT_TYPES = new Set(["FrameworkConcept", "Annotation", "PipelineRole"]);

  const TYPE_PRIORITY = {
    Package: 0,
    Module: 1,
    Class: 2,
    Interface: 3,
    PolicyLeaf: 4,
    PipelineRole: 5,
    FrameworkConcept: 6,
    Method: 7,
    Function: 8,
    RuntimeBranch: 9,
  };

  const NODE_COLORS = {
    Java: "#f2aa55",
    Python: "#69b8ff",
    Framework: "#b69cff",
    Concept: "#55d6a2",
    Policy: "#f8c15c",
  };

  const TYPE_SHAPES = {
    Package: "round-rectangle",
    Module: "round-rectangle",
    Interface: "round-rectangle",
    PolicyLeaf: "hexagon",
    RuntimeBranch: "diamond",
    FrameworkAnnotation: "ellipse",
    Decorator: "ellipse",
    PipelineRole: "tag",
  };

  const QUALITY_STATUS_LABELS = {
    supported: "지원",
    partial: "부분 지원",
    unsupported: "미지원",
    unknown: "알 수 없음",
  };

  const EVIDENCE_BASIS_LABELS = {
    direct_syntax: "직접 구문",
    resolved_static: "해석된 정적 관계",
    framework_semantic: "프레임워크 의미",
    name_heuristic: "이름 휴리스틱",
  };

  const dom = {
    repositoryName: document.getElementById("repository-name"),
    snapshotBadge: document.getElementById("snapshot-badge"),
    evidenceBadge: document.getElementById("evidence-badge"),
    warningBadge: document.getElementById("warning-badge"),
    globalSearch: document.getElementById("global-search"),
    searchInput: document.getElementById("search-input"),
    languageFilter: document.getElementById("language-filter"),
    typeFilter: document.getElementById("type-filter"),
    searchResults: document.getElementById("search-results"),
    searchCount: document.getElementById("search-count"),
    lensNav: document.getElementById("lens-nav"),
    viewEyebrow: document.getElementById("view-eyebrow"),
    viewTitle: document.getElementById("view-title"),
    viewDescription: document.getElementById("view-description"),
    graphToolbar: document.getElementById("graph-toolbar"),
    overviewView: document.getElementById("overview-view"),
    graphView: document.getElementById("graph-view"),
    changesView: document.getElementById("changes-view"),
    graph: document.getElementById("graph"),
    graph3d: document.getElementById("graph-3d"),
    graph3dCanvas: document.getElementById("graph-3d-canvas"),
    graph3dSummary: document.getElementById("graph-3d-summary"),
    graph3dStatus: document.getElementById("graph-3d-status"),
    graphTextAlternative: document.getElementById("graph-text-alternative"),
    graphTextSummary: document.getElementById("graph-text-summary"),
    graphTextNodes: document.getElementById("graph-text-nodes"),
    graphTextEdges: document.getElementById("graph-text-edges"),
    graphEmpty: document.getElementById("graph-empty"),
    graphNote: document.getElementById("graph-note"),
    depthSelect: document.getElementById("depth-select"),
    directionSelect: document.getElementById("direction-select"),
    zoomOut: document.getElementById("zoom-out"),
    zoomIn: document.getElementById("zoom-in"),
    fitGraph: document.getElementById("fit-graph"),
    resetView: document.getElementById("reset-view"),
    viewMode2d: document.getElementById("view-mode-2d"),
    viewMode3d: document.getElementById("view-mode-3d"),
    motionToggle: document.getElementById("motion-toggle"),
    metricCards: document.getElementById("metric-cards"),
    qualityPanel: document.getElementById("quality-panel"),
    qualityContract: document.getElementById("quality-contract"),
    qualityContent: document.getElementById("quality-content"),
    nodeTypeBars: document.getElementById("node-type-bars"),
    edgeTypeBars: document.getElementById("edge-type-bars"),
    packageCount: document.getElementById("package-count"),
    packageList: document.getElementById("package-list"),
    guidedActions: document.getElementById("guided-actions"),
    detailsContent: document.getElementById("details-content"),
    liveStatus: document.getElementById("live-status"),
    searchPanel: document.getElementById("search-panel"),
    detailsPanel: document.getElementById("details-panel"),
    closeSearch: document.getElementById("close-search"),
    closeDetails: document.getElementById("close-details"),
    qualityToggle: document.getElementById("quality-toggle"),
    qualityClose: document.getElementById("quality-close"),
    viewBack: document.getElementById("view-back"),
    copyLink: document.getElementById("copy-link"),
    linkStatus: document.getElementById("link-status"),
  };

  function text(value, fallback) {
    if (typeof value === "string") return value;
    if (typeof value === "number" || typeof value === "boolean") return String(value);
    return fallback || "";
  }

  function finiteNumber(value, fallback) {
    const number = Number(value);
    return Number.isFinite(number) ? number : fallback;
  }

  function objectOrEmpty(value) {
    return value && typeof value === "object" && !Array.isArray(value) ? value : {};
  }

  function arrayOrEmpty(value) {
    return Array.isArray(value) ? value : [];
  }

  function make(tagName, className, content) {
    const element = document.createElement(tagName);
    if (className) element.className = className;
    if (content !== undefined && content !== null) element.textContent = text(content);
    return element;
  }

  function append(parent) {
    for (let index = 1; index < arguments.length; index += 1) {
      const child = arguments[index];
      if (child) parent.appendChild(child);
    }
    return parent;
  }

  function setHidden(element, hidden) {
    element.hidden = hidden;
    element.style.display = hidden ? "none" : "";
  }

  function formatCount(value) {
    return new Intl.NumberFormat("ko-KR").format(Math.max(0, finiteNumber(value, 0)));
  }

  function formatDate(value) {
    if (!value) return "생성 시각 없음";
    const parsed = new Date(value);
    if (Number.isNaN(parsed.getTime())) return text(value);
    return new Intl.DateTimeFormat("ko-KR", {
      dateStyle: "medium",
      timeStyle: "short",
    }).format(parsed);
  }

  function announce(message) {
    dom.liveStatus.textContent = "";
    window.setTimeout(function () {
      dom.liveStatus.textContent = message;
    }, 20);
  }

  function ownValue(mapping, key, fallback) {
    return Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : fallback;
  }

  function typeLabel(type) {
    return ownValue(TYPE_LABELS, type, type || "알 수 없는 유형");
  }

  function relationLabel(type) {
    return ownValue(RELATION_LABELS, type, type || "알 수 없는 관계");
  }

  function typePriority(type) {
    return finiteNumber(ownValue(TYPE_PRIORITY, type, 50), 50);
  }

  function shortLabel(value, maximum) {
    const source = text(value);
    const limit = maximum || 34;
    return source.length > limit ? source.slice(0, Math.max(1, limit - 1)) + "…" : source;
  }

  let payload;
  try {
    payload = JSON.parse(document.getElementById("ontology-data").textContent);
  } catch (error) {
    payload = {};
    dom.viewTitle.textContent = "온톨로지 데이터를 읽을 수 없습니다";
    dom.viewDescription.textContent = "내장 JSON이 올바른지 새 스냅샷을 생성해 확인해 주세요.";
    dom.detailsContent.replaceChildren(
      append(
        make("div", "details-placeholder"),
        make("strong", "", "데이터 파싱 실패"),
        make("p", "", error instanceof Error ? error.message : "알 수 없는 오류")
      )
    );
  }

  const meta = objectOrEmpty(payload.meta);
  const statistics = objectOrEmpty(payload.statistics);
  const changes = objectOrEmpty(payload.changes);
  const limits = objectOrEmpty(payload.limits);
  const quality = objectOrEmpty(payload.quality);
  const warnings = arrayOrEmpty(payload.warnings).filter(function (item) {
    return item && typeof item === "object";
  });
  const maxVisibleNodes = Math.max(
    1,
    Math.min(HARD_MAX_VISIBLE_NODES, finiteNumber(limits.maxVisibleNodes, 200))
  );

  const nodes = [];
  const nodeById = new Map();
  arrayOrEmpty(payload.nodes).forEach(function (item) {
    if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id) return;
    if (nodeById.has(item.id)) return;
    const node = {
      id: item.id,
      type: text(item.type, "Unknown"),
      name: text(item.name, item.id),
      language: text(item.language, "Unknown"),
      path: text(item.path),
      qualified_name: text(item.qualified_name || item.qualifiedName),
      metadata: objectOrEmpty(item.metadata),
    };
    nodes.push(node);
    nodeById.set(node.id, node);
  });

  const edges = [];
  const edgeKeys = new Set();
  arrayOrEmpty(payload.edges).forEach(function (item) {
    if (!item || typeof item !== "object") return;
    const source = text(item.source);
    const target = text(item.target);
    const type = text(item.type);
    if (!source || !target || !type || source === target) return;
    if (!nodeById.has(source) || !nodeById.has(target)) return;
    const key = source + "\u0000" + type + "\u0000" + target;
    if (edgeKeys.has(key)) return;
    edgeKeys.add(key);
    edges.push({
      source: source,
      target: target,
      type: type,
      key: key,
      evidence: arrayOrEmpty(item.evidence).filter(function (entry) {
        return entry && typeof entry === "object" && !Array.isArray(entry);
      }),
    });
  });

  edges.sort(function (left, right) {
    return (
      left.source.localeCompare(right.source) ||
      left.type.localeCompare(right.type) ||
      left.target.localeCompare(right.target)
    );
  });

  const edgeByKey = new Map(edges.map(function (edge) { return [edge.key, edge]; }));
  const outgoing = new Map();
  const incoming = new Map();
  edges.forEach(function (edge) {
    if (!outgoing.has(edge.source)) outgoing.set(edge.source, []);
    if (!incoming.has(edge.target)) incoming.set(edge.target, []);
    outgoing.get(edge.source).push(edge);
    incoming.get(edge.target).push(edge);
  });

  function flattenSearchValues(value, result, depth) {
    if (depth > 3 || result.length >= 40) return;
    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
      result.push(String(value));
      return;
    }
    if (Array.isArray(value)) {
      value.slice(0, 20).forEach(function (item) {
        flattenSearchValues(item, result, depth + 1);
      });
      return;
    }
    if (value && typeof value === "object") {
      Object.keys(value)
        .sort()
        .slice(0, 20)
        .forEach(function (key) {
          result.push(key);
          flattenSearchValues(value[key], result, depth + 1);
        });
    }
  }

  nodes.forEach(function (node) {
    const values = [node.id, node.type, node.name, node.language, node.path, node.qualified_name];
    flattenSearchValues(node.metadata, values, 0);
    node.searchText = values.join(" ").toLocaleLowerCase("ko-KR");
    node.nameLower = node.name.toLocaleLowerCase("ko-KR");
    node.qualifiedLower = node.qualified_name.toLocaleLowerCase("ko-KR");
    node.idLower = node.id.toLocaleLowerCase("ko-KR");
  });

  const reducedMotionQuery = typeof window.matchMedia === "function"
    ? window.matchMedia("(prefers-reduced-motion: reduce)")
    : { matches: false, addEventListener: null };

  const state = {
    activeLens: "architecture",
    atlasOverview: true,
    selectionHistory: [],
    restoringSelection: false,
    selectedEvidenceId: "",
    graphSignature: "",
    selectedId: "",
    rootId: "",
    depth: Math.max(1, Math.min(3, finiteNumber(dom.depthSelect.value, 2))),
    direction: dom.directionSelect.value,
    searchMatches: [],
    activeSearchIndex: -1,
    searchVisibleLimit: MAX_SEARCH_RESULTS,
    selectedEdgeKey: "",
    renderedEdgeById: new Map(),
    renderedEdgeIdByKey: new Map(),
    renderToken: 0,
    cy: null,
    viewMode: "3d",
    threeDAvailable: true,
    threeDContext: null,
    threeDGraph: null,
    threeDPositions: new Map(),
    threeDProjectedNodes: [],
    threeDProjectedEdges: [],
    threeDGroups: [],
    groupByNodeId: new Map(),
    cameraTransition: null,
    cameraFocusId: "",
    threeDFrame: 0,
    threeDLastFrameAt: 0,
    threeDLastRenderMs: 0,
    threeDFocusedIndex: 0,
    threeDHoverNodeId: "",
    threeDHoverEdgeKey: "",
    threeDDragging: false,
    threeDDragDistance: 0,
    threeDPointer: { x: 0, y: 0 },
    motionEnabled: !reducedMotionQuery.matches,
    camera: { yaw: -0.38, pitch: -0.16, zoom: 1, distance: 980, x: 0, y: 0, z: 0 },
  };

  function isSpringAnnotation(node) {
    if (!node || node.type !== "FrameworkAnnotation") return false;
    return arrayOrEmpty(node.metadata.semantic_groups).some(function (group) {
      return SPRING_GROUPS.has(text(group));
    });
  }

  function edgeAllowed(edge, lens, rootId) {
    const allowed = RELATION_SETS[lens];
    if (lens === "impact") {
      const concept = function (node) { return !node || GENERIC_CONCEPT_TYPES.has(node.type) || ["Framework", "Concept"].includes(node.language); };
      if (concept(nodeById.get(edge.source)) || concept(nodeById.get(edge.target))) return false;
    }
    if (allowed && !allowed.has(edge.type)) return false;
    if (lens === "spring" && edge.type === "ANNOTATED_BY") {
      return isSpringAnnotation(nodeById.get(edge.target));
    }
    return true;
  }

  function lensEdges(lens, rootId) {
    return edges.filter(function (edge) {
      return edgeAllowed(edge, lens, rootId || "");
    });
  }

  function degreeFor(nodeId, candidateEdges) {
    let degree = 0;
    candidateEdges.forEach(function (edge) {
      if (edge.source === nodeId || edge.target === nodeId) degree += 1;
    });
    return degree;
  }

  function preferredTypesForLens(lens) {
    if (lens === "architecture") return new Set(["Package", "Module", "Class", "Interface"]);
    if (lens === "spring") return new Set(["FrameworkConcept", "Class", "Interface", "Method"]);
    if (lens === "policy") return new Set(["PolicyLeaf"]);
    if (lens === "pipeline") return new Set(["PipelineRole"]);
    return new Set(["Package", "Module", "Class", "PolicyLeaf", "PipelineRole"]);
  }

  function defaultSeed(lens) {
    const candidates = lensEdges(lens, "");
    const preferred = preferredTypesForLens(lens);
    const degrees = new Map();
    candidates.forEach(function (edge) { degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1); degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1); });
    const connected = new Set();
    candidates.forEach(function (edge) {
      connected.add(edge.source);
      connected.add(edge.target);
    });
    const choices = nodes.filter(function (node) {
      return connected.has(node.id) && preferred.has(node.type);
    });
    const pool = choices.length
      ? choices
      : nodes.filter(function (node) {
          return connected.has(node.id);
        });
    pool.sort(function (left, right) {
      return (
        (degrees.get(right.id) || 0) - (degrees.get(left.id) || 0) ||
        left.nameLower.localeCompare(right.nameLower) ||
        left.id.localeCompare(right.id)
      );
    });
    return pool.length ? pool[0].id : nodes.length ? nodes[0].id : "";
  }

  function neighborhood(rootId, lens, depth, direction) {
    if (!nodeById.has(rootId)) return { nodes: [], edges: [], truncated: false };
    const candidates = lensEdges(lens, rootId);
    const localOutgoing = new Map();
    const localIncoming = new Map();
    candidates.forEach(function (edge) {
      if (!localOutgoing.has(edge.source)) localOutgoing.set(edge.source, []);
      if (!localIncoming.has(edge.target)) localIncoming.set(edge.target, []);
      localOutgoing.get(edge.source).push(edge);
      localIncoming.get(edge.target).push(edge);
    });
    const visited = new Set([rootId]);
    const queue = [{ id: rootId, depth: 0 }];
    const selectedRelation = edgeByKey.get(state.selectedEdgeKey);
    if (selectedRelation && edgeAllowed(selectedRelation, lens, rootId) && (selectedRelation.source === rootId || selectedRelation.target === rootId)) {
      const endpoint = selectedRelation.source === rootId ? selectedRelation.target : selectedRelation.source;
      if (visited.size < maxVisibleNodes) { visited.add(endpoint); queue.push({ id: endpoint, depth: 1 }); }
    }
    let truncated = false;

    while (queue.length) {
      const current = queue.shift();
      if (current.depth >= depth) continue;
      const steps = [];
      if (direction !== "incoming") {
        arrayOrEmpty(localOutgoing.get(current.id)).forEach(function (edge) {
          steps.push({ id: edge.target, edge: edge });
        });
      }
      if (direction !== "outgoing") {
        arrayOrEmpty(localIncoming.get(current.id)).forEach(function (edge) {
          steps.push({ id: edge.source, edge: edge });
        });
      }
      steps.sort(function (left, right) {
        return (
          left.edge.type.localeCompare(right.edge.type) ||
          left.id.localeCompare(right.id)
        );
      });
      for (let index = 0; index < steps.length; index += 1) {
        const neighborId = steps[index].id;
        if (visited.has(neighborId)) continue;
        if (visited.size >= maxVisibleNodes) {
          truncated = true;
          break;
        }
        visited.add(neighborId);
        queue.push({ id: neighborId, depth: current.depth + 1 });
      }
      if (truncated && visited.size >= maxVisibleNodes) break;
    }

    const selectedNodes = Array.from(visited)
      .map(function (id) {
        return nodeById.get(id);
      })
      .filter(Boolean);
    const selectedEdges = candidates.filter(function (edge) {
      return visited.has(edge.source) && visited.has(edge.target);
    });
    return { nodes: selectedNodes, edges: selectedEdges, truncated: truncated };
  }

  function populateFacet(select, values, allLabel) {
    const current = select.value;
    const first = make("option", "", allLabel);
    first.value = "";
    const options = [first];
    Array.from(values)
      .sort(function (left, right) {
        return left.localeCompare(right, "ko");
      })
      .forEach(function (value) {
        const option = make("option", "", value);
        option.value = value;
        options.push(option);
      });
    select.replaceChildren.apply(select, options);
    if (values.has(current)) select.value = current;
  }

  function searchScore(node, needle) {
    if (!needle) return typePriority(node.type);
    if (node.nameLower === needle) return 0;
    if (node.qualifiedLower === needle || node.idLower === needle) return 1;
    if (node.nameLower.startsWith(needle)) return 2;
    if (node.qualifiedLower.startsWith(needle)) return 3;
    if (node.idLower.startsWith(needle)) return 4;
    if (node.searchText.includes(needle)) return 5;
    return Number.POSITIVE_INFINITY;
  }

  function runSearch() {
    const needle = dom.searchInput.value.trim().toLocaleLowerCase("ko-KR");
    const language = dom.languageFilter.value;
    const type = dom.typeFilter.value;
    const ranked = [];
    nodes.forEach(function (node) {
      if (language && node.language !== language) return;
      if (type && node.type !== type) return;
      const score = searchScore(node, needle);
      if (!Number.isFinite(score)) return;
      ranked.push({ node: node, score: score });
    });
    ranked.sort(function (left, right) {
      return (
        left.score - right.score ||
        typePriority(left.node.type) - typePriority(right.node.type) ||
        left.node.nameLower.localeCompare(right.node.nameLower) ||
        left.node.id.localeCompare(right.node.id)
      );
    });
    state.searchMatches = ranked.map(function (item) {
      return item.node;
    });
    state.activeSearchIndex = state.searchMatches.length ? 0 : -1;
    state.searchVisibleLimit = MAX_SEARCH_RESULTS;
    renderSearchResults();
  }

  function resultContext(node) {
    return node.path || node.qualified_name || node.id;
  }

  function renderSearchResults() {
    const visible = state.searchMatches.slice(0, state.searchVisibleLimit);
    dom.searchCount.textContent = formatCount(state.searchMatches.length);
    if (!visible.length) {
      dom.searchResults.replaceChildren(
        make("div", "empty-results", "일치하는 심볼이 없습니다. 검색어나 필터를 바꾸어 보세요.")
      );
      dom.searchInput.removeAttribute("aria-activedescendant");
      return;
    }
    const fragment = document.createDocumentFragment();
    visible.forEach(function (node, index) {
      const button = make("button", "search-result");
      button.type = "button";
      button.id = "search-option-" + index;
      button.dataset.nodeId = node.id;
      button.setAttribute("role", "option");
      button.setAttribute("aria-selected", node.id === state.selectedId ? "true" : "false");
      if (node.id === state.selectedId) button.classList.add("is-selected");
      if (index === state.activeSearchIndex) button.dataset.active = "true";
      const title = make("span", "result-title", node.name);
      const metaRow = make("span", "result-meta");
      append(
        metaRow,
        make("span", "type-chip", typeLabel(node.type)),
        make("span", "", node.language)
      );
      append(button, title, metaRow, make("span", "result-context", resultContext(node)));
      button.addEventListener("click", function () {
        focusAsRoot(node.id);
        const replacement = Array.from(dom.searchResults.querySelectorAll("[data-node-id]")).find(function (option) {
          return option.dataset.nodeId === node.id;
        });
        if (replacement) dom.graph3dCanvas.focus({ preventScroll: true });
      });
      fragment.appendChild(button);
    });
    if (visible.length < state.searchMatches.length) {
      const more = make("button", "more-button", "검색 결과 더 보기 · " + formatCount(state.searchMatches.length - visible.length));
      more.type = "button";
      more.addEventListener("click", function () {
        const nextIndex = state.searchVisibleLimit;
        state.searchVisibleLimit += MAX_SEARCH_RESULTS;
        renderSearchResults();
        const next = document.getElementById("search-option-" + nextIndex);
        if (next) next.focus({ preventScroll: true });
      });
      fragment.appendChild(more);
    }
    dom.searchResults.replaceChildren(fragment);
    syncActiveSearchOption(false);
  }

  function syncActiveSearchOption(scroll) {
    const visibleCount = Math.min(state.searchMatches.length, state.searchVisibleLimit);
    if (!visibleCount || state.activeSearchIndex < 0) {
      dom.searchInput.removeAttribute("aria-activedescendant");
      return;
    }
    state.activeSearchIndex = Math.max(0, Math.min(visibleCount - 1, state.activeSearchIndex));
    const options = dom.searchResults.querySelectorAll("[role='option']");
    options.forEach(function (option, index) {
      if (index === state.activeSearchIndex) {
        option.dataset.active = "true";
      } else {
        delete option.dataset.active;
      }
    });
    const active = document.getElementById("search-option-" + state.activeSearchIndex);
    if (active) {
      dom.searchInput.setAttribute("aria-activedescendant", active.id);
      if (scroll) active.scrollIntoView({ block: "nearest" });
    }
  }

  function metricCard(label, value, note) {
    return append(
      make("article", "metric-card"),
      make("div", "metric-label", label),
      make("div", "metric-value", value),
      make("div", "metric-note", note)
    );
  }

  function countBy(items, key) {
    const counts = Object.create(null);
    items.forEach(function (item) {
      const value = text(item[key], "Unknown");
      counts[value] = (counts[value] || 0) + 1;
    });
    return counts;
  }

  function qualityStatus(value) {
    const normalized = text(value).toLowerCase();
    return Object.prototype.hasOwnProperty.call(QUALITY_STATUS_LABELS, normalized)
      ? normalized
      : "unknown";
  }

  function qualityStatusChip(value, prefix) {
    const status = qualityStatus(value);
    return make(
      "span",
      "quality-status quality-status--" + status,
      (prefix ? prefix + " · " : "") + QUALITY_STATUS_LABELS[status]
    );
  }

  function qualityCountChip(label, count) {
    const chip = make("span", "quality-chip");
    append(chip, make("span", "", label), make("strong", "", formatCount(count)));
    return chip;
  }

  function qualityMap(value) {
    const result = objectOrEmpty(value);
    return Object.keys(result).length ? result : {};
  }

  function qualityField(object, snakeCaseName, camelCaseName, fallback) {
    if (object[snakeCaseName] !== undefined && object[snakeCaseName] !== null) {
      return object[snakeCaseName];
    }
    if (object[camelCaseName] !== undefined && object[camelCaseName] !== null) {
      return object[camelCaseName];
    }
    return fallback;
  }

  function renderQualityPanel() {
    const qualityContract = text(
      quality.contract_version || quality.contractVersion || quality.status,
      "legacy_unknown"
    ).toLowerCase();
    if (!Object.keys(quality).length || qualityContract === "legacy_unknown") {
      dom.qualityPanel.dataset.qualityState = "legacy";
      dom.qualityContract.textContent = "legacy snapshot";
      dom.qualityContent.replaceChildren(
        make(
          "p",
          "quality-legacy",
          "이 스냅샷에는 품질 계약 메타데이터가 없습니다. 증거 범위를 추정하지 않고 기존 정적 관계 탐색을 계속 제공합니다."
        )
      );
      return;
    }

    dom.qualityPanel.dataset.qualityState = "available";
    dom.qualityContract.textContent = "contract " + text(quality.contract_version || quality.contractVersion, "unknown");
    const relationship = objectOrEmpty(
      quality.relationship_evidence || quality.relationshipEvidence
    );
    const totalEdges = Math.max(
      0,
      finiteNumber(qualityField(relationship, "total_edges", "totalEdges", edges.length), edges.length)
    );
    const documentedEdges = Math.max(
      0,
      finiteNumber(qualityField(relationship, "documented_edges", "documentedEdges", 0), 0)
    );
    const missingEvidence = Math.max(
      0,
      finiteNumber(
        qualityField(
          relationship,
          "missing_evidence",
          "missingEvidence",
          Math.max(0, totalEdges - documentedEdges)
        ),
        Math.max(0, totalEdges - documentedEdges)
      )
    );
    const coverageValue = finiteNumber(
      qualityField(
        relationship,
        "coverage_percent",
        "coveragePercent",
        totalEdges ? (documentedEdges / totalEdges) * 100 : 0
      ),
      totalEdges ? (documentedEdges / totalEdges) * 100 : 0
    );
    const coverage = Math.max(0, Math.min(100, coverageValue));
    const summary = make("div", "quality-summary");
    append(
      summary,
      append(
        make("div", "quality-stat"),
        make("strong", "", formatCount(documentedEdges) + " / " + formatCount(totalEdges)),
        make("span", "", "근거가 연결된 관계")
      ),
      append(
        make("div", "quality-stat"),
        make("strong", "", formatCount(Object.keys(qualityMap(quality.adapters)).length)),
        make("span", "", "언어 어댑터")
      ),
      append(
        make("div", "quality-stat"),
        make("strong", "", formatCount(missingEvidence)),
        make("span", "", "증거 메타데이터가 없는 관계")
      )
    );

    const basisSection = make("section", "quality-subsection");
    basisSection.appendChild(make("h4", "", "증거 근거"));
    const basisList = make("div", "quality-chip-list");
    const basisCounts = qualityMap(relationship.basis_counts || relationship.basisCounts);
    Object.keys(basisCounts)
      .sort()
      .forEach(function (basis) {
        basisList.appendChild(
          qualityCountChip(ownValue(EVIDENCE_BASIS_LABELS, basis, basis), basisCounts[basis])
        );
      });
    if (!Object.keys(basisCounts).length) {
      basisList.appendChild(make("span", "details-subtitle", "근거 집계 없음"));
    }
    basisSection.appendChild(basisList);

    const adapterSection = make("section", "quality-subsection");
    adapterSection.appendChild(make("h4", "", "언어 어댑터"));
    const adapterList = make("div", "adapter-list");
    const adapters = qualityMap(quality.adapters);
    Object.keys(adapters)
      .sort()
      .forEach(function (language) {
        const adapter = objectOrEmpty(adapters[language]);
        const row = make("div", "adapter-row");
        const heading = make("div", "adapter-heading");
        append(
          heading,
          make("strong", "", language + (adapter.detected === false ? " · 미검출" : "")),
          qualityStatusChip(adapter.status)
        );
        row.appendChild(heading);
        const capabilities = qualityMap(adapter.capabilities);
        if (Object.keys(capabilities).length) {
          const capabilityList = make("div", "adapter-capabilities");
          Object.keys(capabilities)
            .sort()
            .forEach(function (capability) {
              capabilityList.appendChild(qualityStatusChip(capabilities[capability], capability));
            });
          row.appendChild(capabilityList);
        }
        const unsupportedRuntime = arrayOrEmpty(
          adapter.unsupported_runtime || adapter.unsupportedRuntime
        )
          .map(function (item) { return text(item); })
          .filter(Boolean);
        if (unsupportedRuntime.length) {
          row.appendChild(
            make(
              "p",
              "adapter-runtime-gap",
              "런타임 미지원: " + unsupportedRuntime.join(", ")
            )
          );
        }
        adapterList.appendChild(row);
      });
    if (!Object.keys(adapters).length) {
      adapterList.appendChild(make("span", "details-subtitle", "어댑터 상태 없음"));
    }
    adapterSection.appendChild(adapterList);

    const grid = make("div", "quality-grid");
    append(grid, basisSection, adapterSection);
    const content = document.createDocumentFragment();
    append(content, summary, grid);

    const runtimeCounts = qualityMap(
      relationship.runtime_status_counts || relationship.runtimeStatusCounts
    );
    const runtimeUnknown = Math.max(
      0,
      finiteNumber(qualityField(runtimeCounts, "runtime_unknown", "runtimeUnknown", 0), 0)
    );
    if (runtimeUnknown > 0) {
      content.appendChild(
        make(
          "p",
          "quality-runtime-warning",
          "런타임 확인 안 됨 · " + formatCount(runtimeUnknown) +
            "개 관계는 정적 증거만 있으며 실제 실행·활성화 여부를 입증하지 않습니다."
        )
      );
    }
    const interpretation = text(quality.interpretation);
    if (interpretation) {
      content.appendChild(make("p", "quality-interpretation", interpretation));
    }
    dom.qualityContent.replaceChildren(content);
  }

  function renderBars(container, counts, labeler) {
    const entries = Object.keys(counts).map(function (key) {
      return [key, finiteNumber(counts[key], 0)];
    });
    entries.sort(function (left, right) {
      return right[1] - left[1] || left[0].localeCompare(right[0]);
    });
    const maximum = entries.length ? Math.max(1, entries[0][1]) : 1;
    const fragment = document.createDocumentFragment();
    entries.forEach(function (entry) {
      const row = make("div", "bar-item");
      const track = make("div", "bar-track");
      const fill = make("div", "bar-fill");
      fill.style.width = Math.max(1, (entry[1] / maximum) * 100) + "%";
      append(track, fill);
      append(
        row,
        make("div", "bar-label", labeler(entry[0])),
        track,
        make("div", "bar-value", formatCount(entry[1]))
      );
      fragment.appendChild(row);
    });
    if (!entries.length) fragment.appendChild(make("div", "empty-results", "집계 데이터가 없습니다."));
    container.replaceChildren(fragment);
  }

  function sourceFileCount() {
    const sourceFiles = objectOrEmpty(statistics.sourceFiles || statistics.source_files);
    return Object.keys(sourceFiles).reduce(function (sum, key) {
      return sum + finiteNumber(sourceFiles[key], 0);
    }, 0);
  }

  function renderOverview() {
    dom.metricCards.replaceChildren(
      metricCard("파일", formatCount(sourceFileCount()), ""),
      metricCard("심볼", formatCount(finiteNumber(statistics.nodes, nodes.length)), ""),
      metricCard("관계", formatCount(finiteNumber(statistics.edges, edges.length)), ""),
      metricCard("분석 경고", formatCount(finiteNumber(statistics.warnings, warnings.length)), "")
    );
  }

  function relationPhrase(edge, direction) {
    if (edge.type === "READS_POLICY_LEAF") {
      return direction === "outgoing" ? "이 심볼이 읽는 정책 값" : "이 정책 값을 읽는 메서드";
    }
    if (edge.type === "DECLARES_RUNTIME_BRANCH") {
      return direction === "outgoing" ? "이 메서드의 조건 분기" : "이 분기를 선언한 메서드";
    }
    if (edge.type === "GUARDS_RUNTIME_BRANCH") {
      return direction === "outgoing" ? "이 정책 값이 제어하는 분기" : "이 분기를 제어하는 정책 값";
    }
    return relationLabel(edge.type) + (direction === "outgoing" ? " · 나가는 관계" : " · 들어오는 관계");
  }

  function ownerTrail(nodeId) {
    const result = [];
    const seen = new Set([nodeId]);
    let current = nodeId;
    for (let depth = 0; depth < 6; depth += 1) {
      const ownerEdge = arrayOrEmpty(incoming.get(current))
        .filter(function (edge) {
          return edge.type === "DECLARES" && !seen.has(edge.source);
        })
        .sort(function (left, right) {
          return left.source.localeCompare(right.source);
        })[0];
      if (!ownerEdge) break;
      const owner = nodeById.get(ownerEdge.source);
      if (!owner) break;
      result.unshift(owner.name);
      seen.add(owner.id);
      current = owner.id;
    }
    return result;
  }

  function propertyRow(label, value) {
    const row = make("div", "property-row");
    append(row, make("dt", "", label), make("dd", "", value || "—"));
    return row;
  }

  function groupedRelations(nodeId, direction) {
    const sourceEdges = direction === "outgoing" ? arrayOrEmpty(outgoing.get(nodeId)) : arrayOrEmpty(incoming.get(nodeId));
    const groups = new Map();
    sourceEdges.forEach(function (edge) {
      const key = edge.type;
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(edge);
    });
    return Array.from(groups.entries()).sort(function (left, right) {
      return left[0].localeCompare(right[0]);
    });
  }

  function neighborButton(edge, direction) {
    const neighborId = direction === "outgoing" ? edge.target : edge.source;
    const neighbor = nodeById.get(neighborId);
    const button = make("button", "neighbor-button", neighbor ? neighbor.name : neighborId);
    button.type = "button";
    button.title = neighbor ? resultContext(neighbor) : neighborId;
    button.addEventListener("click", function () {
      selectNode(neighborId, false);
    });
    return button;
  }

  function renderRelationGroups(container, nodeId, direction) {
    const groups = groupedRelations(nodeId, direction);
    groups.forEach(function (entry) {
      const group = make("div", "relation-group");
      const heading = append(make("div", "relation-group-title"), make("span", "", relationPhrase(entry[1][0], direction)), make("span", "relation-chip", formatCount(entry[1].length)));
      const list = make("div", "neighbor-list");
      const sorted = entry[1].slice().sort(function (left, right) { return left.key.localeCompare(right.key); });
      let shown = 0;
      const more = make("button", "more-button");
      more.type = "button";
      function showPage() {
        sorted.slice(shown, shown + MAX_DETAIL_NEIGHBORS).forEach(function (edge) { list.appendChild(neighborButton(edge, direction)); });
        shown = Math.min(sorted.length, shown + MAX_DETAIL_NEIGHBORS);
        more.textContent = "더 보기 · " + formatCount(sorted.length - shown);
        setHidden(more, shown >= sorted.length);
      }
      more.addEventListener("click", showPage);
      showPage();
      append(group, heading, list, more);
      container.appendChild(group);
    });
    if (!groups.length) container.appendChild(make("div", "empty-results", "연결 없음"));
  }

  function renderDetails(node) {
    if (!node) return;
    setHidden(dom.detailsPanel, false);
    setHidden(dom.searchPanel, true);
    dom.searchInput.setAttribute("aria-expanded", "false");
    const header = make("div", "details-header");
    append(
      header,
      make("span", "type-chip", typeLabel(node.type)),
      make("h3", "", node.name),
      make("div", "details-subtitle", node.qualified_name || node.path || node.id)
    );
    const actions = make("div", "details-actions");
    const centerButton = make("button", "primary-button", "이 심볼 중심으로 보기");
    centerButton.type = "button";
    centerButton.addEventListener("click", function () { focusAsRoot(node.id); });
    const searchButton = make("button", "secondary-button", "의존 관계");
    searchButton.type = "button";
    searchButton.addEventListener("click", function () {
      switchLens("impact", node.id);
    });
    append(actions, centerButton, searchButton);

    const properties = make("section", "details-section");
    properties.appendChild(make("h4", "", "속성"));
    const list = make("dl", "property-list");
    const trail = ownerTrail(node.id);
    append(
      list,
      propertyRow("유형", typeLabel(node.type)),
      propertyRow("언어", node.language),
      propertyRow("소유 구조", trail.length ? trail.join(" › ") : "—"),
      propertyRow("정규 이름", node.qualified_name),
      propertyRow("상대 경로", node.path)
    );
    const line = node.metadata.line || node.metadata.line_start;
    if (line) list.appendChild(propertyRow("줄", text(line)));
    if (["ExternalType", "ExternalModule", "ExternalCallable"].includes(node.type)) list.appendChild(propertyRow("경계", "외부 / 미해결 대상"));
    properties.appendChild(list);

    const outgoingSection = make("section", "details-section");
    outgoingSection.appendChild(make("h4", "", "나가는 관계"));
    renderRelationGroups(outgoingSection, node.id, "outgoing");
    const incomingSection = make("section", "details-section");
    incomingSection.appendChild(make("h4", "", "들어오는 관계"));
    renderRelationGroups(incomingSection, node.id, "incoming");

    const relatedWarnings = warnings.filter(function (warning) {
      return node.path && text(warning.path) === node.path;
    });
    let warningSection = null;
    if (relatedWarnings.length) {
      warningSection = make("section", "details-section");
      warningSection.appendChild(make("h4", "", "이 파일의 분석 경고"));
      relatedWarnings.forEach(function (warning) {
        warningSection.appendChild(make("p", "details-subtitle", text(warning.message, "분석 경고")));
      });
    }


    dom.detailsContent.replaceChildren(header, actions, properties, outgoingSection, incomingSection);
    if (warningSection) dom.detailsContent.appendChild(warningSection);

  }

  function evidenceBasisLabel(value) {
    const basis = text(value);
    return ownValue(EVIDENCE_BASIS_LABELS, basis, basis || "근거 미상");
  }

  function evidenceSourceLocation(item) {
    const path = text(item.path);
    if (
      !path ||
      path.length > 1000 ||
      path.startsWith("/") ||
      path.startsWith("\\") ||
      /^[A-Za-z]:[\\/]/.test(path) ||
      path.split(/[\\/]/).some(function (part) { return part === ".."; }) ||
      Array.from(path).some(function (character) { return character.charCodeAt(0) < 32; })
    ) {
      return "";
    }
    const start = Math.trunc(finiteNumber(item.line_start || item.lineStart, 0));
    const end = Math.trunc(finiteNumber(item.line_end || item.lineEnd, 0));
    if (start < 1) return path;
    return path + ":" + start + (end > start ? "-" + end : "");
  }

  function renderEdgeEvidenceCard(item) {
    const evidence = objectOrEmpty(item);
    const evidenceId = text(evidence.evidence_id || evidence.id);
    const card = make("article", "edge-evidence-card" + (evidenceId && evidenceId === state.selectedEvidenceId ? " is-selected" : ""));
    if (evidenceId) card.dataset.evidenceId = evidenceId;
    const heading = make("div", "edge-evidence-heading");
    append(
      heading,
      make("strong", "", text(evidence.rule_id || evidence.ruleId, "규칙 미상")),
      make("span", "quality-chip", evidenceBasisLabel(evidence.basis))
    );
    const currentEdge = edgeByKey.get(state.selectedEdgeKey);
    if (evidenceId && currentEdge && currentEdge.evidence.some(function (item) { return text(item.evidence_id || item.id) === evidenceId; })) {
      const link = make("button", "icon-button", "↗"); link.type = "button"; link.setAttribute("aria-label", "이 근거 선택");
      link.addEventListener("click", function () { state.selectedEvidenceId = evidenceId; updateSelectionLink(); const edge = edgeByKey.get(state.selectedEdgeKey); if (edge) renderEdgeDetails(edge); });
      heading.appendChild(link);
    }
    card.appendChild(heading);
    const location = evidenceSourceLocation(evidence);
    if (location) card.appendChild(make("div", "edge-evidence-location", location));
    const runtimeStatus = text(evidence.runtime_status || evidence.runtimeStatus);
    if (runtimeStatus === "runtime_unknown") {
      card.appendChild(
        make(
          "div",
          "edge-evidence-runtime",
          "런타임 확인 안 됨 · 이 규칙은 정적 관계만 설명합니다."
        )
      );
    } else if (runtimeStatus === "not_applicable") {
      card.appendChild(make("div", "details-subtitle", "런타임 상태: 해당 없음"));
    }
    const limitations = arrayOrEmpty(evidence.limitations)
      .map(function (itemValue) { return text(itemValue); })
      .filter(Boolean)
      .slice(0, 12);
    if (limitations.length) {
      const list = make("div", "edge-evidence-limitations");
      limitations.forEach(function (limitation) {
        list.appendChild(make("span", "quality-chip", limitation));
      });
      card.appendChild(list);
    }
    return card;
  }

  function renderEdgeDetails(edge) {
    if (!edge) return;
    setHidden(dom.detailsPanel, false);
    state.selectedEdgeKey = edge.key;
    if (state.selectedId !== edge.source && state.selectedId !== edge.target) state.selectedId = edge.source;
    if (state.selectedEvidenceId && !edge.evidence.some(function (item) { return text(item.evidence_id || item.id) === state.selectedEvidenceId; })) state.selectedEvidenceId = "";
    updateViewHeading();
    updateSelectionLink();
    const source = nodeById.get(edge.source);
    const target = nodeById.get(edge.target);
    const header = make("div", "details-header");
    append(
      header,
      make("span", "relation-chip", relationLabel(edge.type)),
      make(
        "h3",
        "",
        (source ? source.name : edge.source) + " → " + (target ? target.name : edge.target)
      ),
      make("div", "details-subtitle", "선택한 관계의 정적 증거")
    );
    const properties = make("section", "details-section");
    append(
      properties,
      make("h4", "", "관계"),
      append(
        make("dl", "property-list"),
        propertyRow("출발", source ? source.qualified_name || source.name : edge.source),
        propertyRow("도착", target ? target.qualified_name || target.name : edge.target),
        propertyRow("유형", relationLabel(edge.type)),
        propertyRow("증거", formatCount(edge.evidence.length) + "건")
      )
    );
    const evidenceSection = make("section", "details-section");
    evidenceSection.appendChild(make("h4", "", "관계 증거"));
    const evidenceList = make("div", "edge-evidence-list");
    let shownEvidence = 0;
    const moreEvidence = make("button", "more-button");
    moreEvidence.type = "button";
    const selectedIndex = edge.evidence.findIndex(function (item) { return text(item.evidence_id || item.id) === state.selectedEvidenceId; });
    function showEvidencePage() {
      const end = Math.min(edge.evidence.length, Math.max(shownEvidence + 12, selectedIndex + 1));
      edge.evidence.slice(shownEvidence, end).forEach(function (item) { evidenceList.appendChild(renderEdgeEvidenceCard(item)); });
      shownEvidence = end;
      moreEvidence.textContent = "근거 더 보기 · " + formatCount(edge.evidence.length - shownEvidence);
      setHidden(moreEvidence, shownEvidence >= edge.evidence.length);
    }
    moreEvidence.addEventListener("click", showEvidencePage);
    showEvidencePage();
    if (!edge.evidence.length) evidenceList.appendChild(make("p", "quality-legacy", "구조화된 근거 없음"));
    evidenceSection.appendChild(moreEvidence);
    evidenceSection.insertBefore(evidenceList, moreEvidence);
    evidenceSection.appendChild(
      make(
        "p",
        "edge-evidence-note",
        "정적 근거 · 소스 본문은 이 패널에 포함하지 않습니다."
      )
    );
    dom.detailsContent.replaceChildren(header, properties, evidenceSection);
  }

  function renderRemovedDetails(item) {
    setHidden(dom.detailsPanel, false);
    const node = objectOrEmpty(item);
    const header = make("div", "details-header");
    append(
      header,
      make("span", "type-chip", typeLabel(text(node.type, "Unknown"))),
      make("h3", "change-removed", text(node.name, text(node.id, "삭제된 심볼"))),
      make("div", "details-subtitle", "현재 스냅샷에는 없는 심볼입니다.")
    );
    const section = make("section", "details-section");
    append(
      section,
      make("h4", "", "이전 스냅샷 정보"),
      append(
        make("dl", "property-list"),
        propertyRow("식별자", text(node.id)),
        propertyRow("정규 이름", text(node.qualified_name || node.qualifiedName)),
        propertyRow("상대 경로", text(node.path)),
        propertyRow("언어", text(node.language))
      )
    );
    dom.detailsContent.replaceChildren(header, section);
  }

  function selectionUrl() {
    const url = new URL(window.location.href);
    url.search = "";
    url.hash = "";
    url.searchParams.set("lens", state.activeLens);
    if (meta.snapshotId) url.searchParams.set("snapshot", text(meta.snapshotId));
    if (state.selectedId) url.searchParams.set("entity", state.selectedId);
    if (state.selectedEdgeKey) url.searchParams.set("edge", state.selectedEdgeKey);
    if (state.selectedEvidenceId) url.searchParams.set("evidence", state.selectedEvidenceId);
    return url;
  }

  function updateSelectionLink() {
    if (state.restoringSelection) return;
    try { window.history.replaceState(null, "", selectionUrl().href); } catch (error) { /* File viewers may disallow history updates; the explicit link remains available. */ }
  }

  function linkFailure(message) {
    dom.linkStatus.textContent = message;
    setHidden(dom.linkStatus, false);
    announce(message);
    return null;
  }

  function readSelectionLink() {
    const params = new URLSearchParams(window.location.search);
    const entity = params.get("entity") || "";
    const edgeKey = params.get("edge") || "";
    const evidenceId = params.get("evidence") || "";
    const snapshot = params.get("snapshot") || "";
    if ((entity || edgeKey || evidenceId) && (!snapshot || snapshot !== text(meta.snapshotId))) return linkFailure("이 링크는 현재 스냅샷의 근거를 가리키지 않습니다.");
    if (snapshot && snapshot !== text(meta.snapshotId)) return linkFailure("스냅샷이 다릅니다. 현재 코드 지도를 표시합니다.");
    if (entity && !nodeById.has(entity)) return linkFailure("이 스냅샷에 해당 심볼이 없습니다.");
    const edge = edgeKey ? edgeByKey.get(edgeKey) : null;
    if (edgeKey && (!edge || (entity && edge.source !== entity && edge.target !== entity))) return linkFailure("심볼과 관계의 근거 연결을 확인할 수 없습니다.");
    if (evidenceId && (!edge || !edge.evidence.some(function (item) { return text(item.evidence_id || item.id) === evidenceId; }))) return linkFailure("해당 관계에 연결된 근거가 아닙니다.");
    const requested = params.get("lens") || "architecture";
    return { lens: edge ? "architecture" : LENS_COPY[requested] ? requested : LENS_ALIASES[requested] || "architecture", entity: entity || (edge ? edge.source : ""), edge: edge, evidenceId: evidenceId };
  }

  function updateViewHeading() {
    const node = nodeById.get(state.selectedId || (state.activeLens === "impact" ? state.rootId : ""));
    dom.viewEyebrow.textContent = LENS_COPY[state.activeLens].eyebrow;
    dom.viewTitle.textContent = state.activeLens === "changes" ? "변경된 코드" : node ? node.name : text(meta.repositoryName, "코드 지도");
    dom.viewDescription.textContent = state.activeLens === "changes" ? "" : state.activeLens === "impact" ? "정적 의존 후보 · " + (state.direction === "incoming" ? "이 코드에 의존" : state.direction === "outgoing" ? "이 코드의 의존 대상" : "양방향") : node ? shortLabel(node.path || node.qualified_name, 85) : "";
  }

  function rememberSelection() {
    if (state.restoringSelection) return;
    const previous = { lens: state.activeLens, rootId: state.rootId, selectedId: state.selectedId, edgeKey: state.selectedEdgeKey, evidenceId: state.selectedEvidenceId, overview: state.atlasOverview, camera: Object.assign({}, state.camera) };
    const last = state.selectionHistory[state.selectionHistory.length - 1];
    if (!last || last.lens !== previous.lens || last.rootId !== previous.rootId || last.selectedId !== previous.selectedId || last.edgeKey !== previous.edgeKey || last.overview !== previous.overview) state.selectionHistory.push(previous);
    if (state.selectionHistory.length > 40) state.selectionHistory.shift();
    dom.viewBack.disabled = !state.selectionHistory.length;
  }

  function goBack() {
    const previous = state.selectionHistory.pop();
    if (!previous) return;
    state.restoringSelection = true;
    state.atlasOverview = previous.overview;
    state.rootId = previous.rootId;
    state.selectedId = previous.selectedId;
    state.selectedEdgeKey = previous.edgeKey;
    state.selectedEvidenceId = previous.evidenceId;
    switchLens(previous.lens, "", true);
    state.camera = previous.camera;
    state.cameraTransition = null;
    if (state.selectedEdgeKey) renderEdgeDetails(edgeByKey.get(state.selectedEdgeKey));
    else if (state.selectedId) renderDetails(nodeById.get(state.selectedId));
    else setHidden(dom.detailsPanel, true);
    state.restoringSelection = false;
    dom.viewBack.disabled = !state.selectionHistory.length;
    updateSelectionLink();
    draw3dScene(0);
  }

  function selectNode(nodeId, updateGraphSelection) {
    const node = nodeById.get(nodeId);
    if (!node) return;
    if (state.selectedId !== nodeId || state.selectedEdgeKey) rememberSelection();
    state.selectedId = nodeId;
    state.selectedEdgeKey = "";
    state.selectedEvidenceId = "";
    renderDetails(node);
    renderSearchResults();
    if (state.cy) {
      state.cy.elements().unselect();
      const element = state.cy.getElementById(nodeId);
      if (element && element.length) element.select();
    }
    if (updateGraphSelection || !state.threeDPositions.has(nodeId) || (state.activeLens === "impact" && state.rootId !== nodeId)) {
      state.rootId = nodeId;
      state.atlasOverview = false;
      renderGraph();
    } else {
      syncGraphTextSelection();
      focus3dCamera(nodeId);
    }
    updateViewHeading();
    updateSelectionLink();
    announce(node.name + " 선택됨");
  }

  function focusAsRoot(nodeId) {
    if (!nodeById.has(nodeId)) return;
    rememberSelection();
    state.atlasOverview = false;
    state.selectedId = nodeId;
    state.selectedEdgeKey = "";
    state.selectedEvidenceId = "";
    state.rootId = nodeId;
    renderDetails(nodeById.get(nodeId));
    if (state.activeLens === "changes") switchLens("architecture", nodeId, true);
    else { renderSearchResults(); renderGraph(); updateViewHeading(); }
    focus3dCamera(nodeId);
    updateSelectionLink();
  }

  function cyNodeData(node, rootId) {
    return {
      id: node.id,
      label: shortLabel(node.name, 36),
      type: node.type,
      language: node.language,
      color: ownValue(NODE_COLORS, node.language, "#ff8790"),
      shape: ownValue(TYPE_SHAPES, node.type, "round-rectangle"),
      root: node.id === rootId ? "yes" : "no",
    };
  }

  function graphElements(graph) {
    state.renderedEdgeById.clear();
    state.renderedEdgeIdByKey.clear();
    const elements = graph.nodes.map(function (node) {
      return {
        group: "nodes",
        data: cyNodeData(node, state.rootId),
        classes: node.id === state.rootId ? "root" : "",
      };
    });
    graph.edges.forEach(function (edge, index) {
      const edgeId = "edge-" + index;
      state.renderedEdgeById.set(edgeId, edge);
      state.renderedEdgeIdByKey.set(edge.key, edgeId);
      elements.push({
        group: "edges",
        data: {
          id: edgeId,
          source: edge.source,
          target: edge.target,
          type: edge.type,
          label: relationLabel(edge.type),
        },
      });
    });
    return elements;
  }

  function ensureCytoscape() {
    if (state.cy) return true;
    if (typeof window.cytoscape !== "function") return false;
    state.cy = window.cytoscape({
      container: dom.graph,
      elements: [],
      minZoom: 0.12,
      maxZoom: 3,
      wheelSensitivity: 0.22,
      selectionType: "single",
      boxSelectionEnabled: false,
      style: [
        {
          selector: "node",
          style: {
            width: 34,
            height: 34,
            shape: "data(shape)",
            "background-color": "data(color)",
            "background-opacity": 0.92,
            "border-width": 1.2,
            "border-color": "#d7eee5",
            "border-opacity": 0.62,
            label: "data(label)",
            color: "#dcebe5",
            "font-size": 9,
            "font-weight": 600,
            "text-valign": "bottom",
            "text-halign": "center",
            "text-margin-y": 7,
            "text-wrap": "ellipsis",
            "text-max-width": 138,
            "text-outline-width": 2,
            "text-outline-color": "#081510",
          },
        },
        {
          selector: "node.root",
          style: {
            width: 48,
            height: 48,
            "border-width": 4,
            "border-color": "#65f0ba",
            "background-opacity": 1,
            "font-size": 11,
            "font-weight": 800,
          },
        },
        {
          selector: "node:selected",
          style: {
            "overlay-opacity": 0,
            "border-width": 4,
            "border-color": "#ffffff",
          },
        },
        {
          selector: "edge",
          style: {
            width: 1.3,
            "line-color": "#5b7b70",
            "target-arrow-color": "#75a697",
            "target-arrow-shape": "triangle",
            "arrow-scale": 0.75,
            "curve-style": "bezier",
            opacity: 0.7,
            label: "data(label)",
            color: "#8ca69d",
            "font-size": 7,
            "text-rotation": "autorotate",
            "text-margin-y": -8,
            "text-background-color": "#0a1814",
            "text-background-opacity": 0.82,
            "text-background-padding": 2,
          },
        },
        {
          selector: "edge[type = 'GUARDS_RUNTIME_BRANCH']",
          style: {
            "line-color": "#f8c15c",
            "target-arrow-color": "#f8c15c",
            width: 2.2,
          },
        },
        {
          selector: "edge:selected",
          style: {
            "line-color": "#65f0ba",
            "target-arrow-color": "#65f0ba",
            width: 3,
            opacity: 1,
            "z-index": 12,
          },
        },
        {
          selector: ".faded",
          style: { opacity: 0.1, "text-opacity": 0.08 },
        },
        {
          selector: ".highlighted",
          style: { opacity: 1, "z-index": 10 },
        },
      ],
    });
    state.cy.on("tap", "node", function (event) {
      selectNode(event.target.id(), false);
    });
    state.cy.on("tap", "edge", function (event) {
      const edge = state.renderedEdgeById.get(event.target.id());
      if (!edge) return;
      rememberSelection();
      state.selectedEdgeKey = edge.key;
      state.cy.elements().unselect();
      event.target.select();
      renderEdgeDetails(edge);
      syncGraphTextSelection();
      announce(relationLabel(edge.type) + " 관계 증거 선택됨");
    });
    state.cy.on("mouseover", "node", function (event) {
      state.cy.elements().addClass("faded");
      event.target.closedNeighborhood().removeClass("faded").addClass("highlighted");
    });
    state.cy.on("mouseout", "node", function () {
      state.cy.elements().removeClass("faded highlighted");
    });
    return true;
  }

  function fallbackLayout(token, rootId) {
    if (!state.cy || token !== state.renderToken) return;
    const nodesOnly = state.cy.nodes();
    const options = nodesOnly.length <= 1
      ? { name: "grid", fit: true, padding: 44 }
      : {
          name: "breadthfirst",
          directed: true,
          circle: false,
          spacingFactor: 1.35,
          padding: 44,
          roots: state.cy.getElementById(rootId),
          fit: true,
        };
    state.cy.layout(options).run();
    dom.graphNote.textContent += " · 계층형 기본 배치";
  }

  async function layoutWithElk(graph, token) {
    if (!state.cy || token !== state.renderToken) return;
    if (typeof window.ELK !== "function") {
      fallbackLayout(token, state.rootId);
      return;
    }
    const localIdByNode = new Map();
    graph.nodes.forEach(function (node, index) {
      localIdByNode.set(node.id, "n" + index);
    });
    const elkGraph = {
      id: "root",
      layoutOptions: {
        "elk.algorithm": "layered",
        "elk.direction": "RIGHT",
        "elk.edgeRouting": "ORTHOGONAL",
        "elk.spacing.nodeNode": "34",
        "elk.layered.spacing.nodeNodeBetweenLayers": "72",
        "elk.padding": "[top=36,left=36,bottom=36,right=36]",
        "elk.layered.cycleBreaking.strategy": "GREEDY",
      },
      children: graph.nodes.map(function (node) {
        return { id: localIdByNode.get(node.id), width: 154, height: 54 };
      }),
      edges: graph.edges.map(function (edge, index) {
        return {
          id: "e" + index,
          sources: [localIdByNode.get(edge.source)],
          targets: [localIdByNode.get(edge.target)],
        };
      }),
    };
    try {
      const elk = new window.ELK();
      const result = await elk.layout(elkGraph);
      if (!state.cy || token !== state.renderToken) return;
      const originalByLocal = new Map();
      localIdByNode.forEach(function (localId, originalId) {
        originalByLocal.set(localId, originalId);
      });
      const positions = new Map();
      arrayOrEmpty(result.children).forEach(function (child) {
        const originalId = originalByLocal.get(child.id);
        if (originalId) {
          positions.set(originalId, {
            x: finiteNumber(child.x, 0) + finiteNumber(child.width, 0) / 2,
            y: finiteNumber(child.y, 0) + finiteNumber(child.height, 0) / 2,
          });
        }
      });
      if (positions.size !== graph.nodes.length) throw new Error("ELK returned incomplete positions");
      state.cy.nodes().positions(function (element) {
        return positions.get(element.id());
      });
      state.cy.fit(state.cy.elements(), 46);
    } catch (error) {
      fallbackLayout(token, state.rootId);
    }
  }

  const GROUP_COLORS = ["#76e6ee", "#a795f5", "#7ae4bc", "#85baff", "#e9bc87", "#e49fc5"];
  const groupCache = new Map();

  function moduleGroup(node) {
    if (groupCache.has(node.id)) return groupCache.get(node.id);
    let current = node;
    const seen = new Set();
    let owner = null;
    for (let depth = 0; current && depth < 16 && !seen.has(current.id); depth += 1) {
      seen.add(current.id);
      if (current.type === "Module" || current.type === "Package") { owner = current; break; }
      const parent = arrayOrEmpty(incoming.get(current.id)).find(function (edge) { return edge.type === "DECLARES"; });
      current = parent ? nodeById.get(parent.source) : null;
    }
    const path = node.path.replace(/\\/g, "/");
    const directory = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
    const group = owner
      ? { key: owner.id, label: owner.qualified_name || owner.name, kind: typeLabel(owner.type), anchorId: owner.id }
      : directory ? { key: "path:" + directory, label: directory, kind: "소스 폴더", anchorId: "" }
      : { key: path ? "file:" + path : "boundary:" + node.language, label: path || node.language, kind: path ? "소스 파일" : "외부 / 미해결", anchorId: "" };
    groupCache.set(node.id, group);
    return group;
  }

  function atlasGraph() {
    const groups = new Map();
    nodes.forEach(function (node) {
      const key = moduleGroup(node).key;
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(node);
    });
    const buckets = Array.from(groups.entries()).sort(function (a, b) {
      const sourceA = a[1].some(function (node) { return Boolean(node.path); });
      const sourceB = b[1].some(function (node) { return Boolean(node.path); });
      return Number(sourceB) - Number(sourceA) || b[1].length - a[1].length || a[0].localeCompare(b[0]);
    }).slice(0, MAX_MODULE_GROUPS);
    buckets.forEach(function (entry) { entry[1].sort(function (a, b) { return typePriority(a.type) - typePriority(b.type) || a.id.localeCompare(b.id); }); });
    const selected = [];
    const included = new Set();
    if (state.selectedId && nodeById.has(state.selectedId)) { selected.push(nodeById.get(state.selectedId)); included.add(state.selectedId); }
    for (let offset = 0; selected.length < Math.min(MAX_3D_VISIBLE_NODES, maxVisibleNodes); offset += 1) {
      let found = false;
      for (let index = 0; index < buckets.length; index += 1) {
        const node = buckets[index][1][offset];
        if (!node) continue;
        found = true;
        if (!included.has(node.id)) { selected.push(node); included.add(node.id); }
        if (selected.length >= Math.min(MAX_3D_VISIBLE_NODES, maxVisibleNodes)) break;
      }
      if (!found) break;
    }
    return { nodes: selected, edges: edges.filter(function (edge) { return included.has(edge.source) && included.has(edge.target); }), truncated: selected.length < nodes.length };
  }

  function buildModuleLayout(graph) {
    const grouped = new Map();
    graph.nodes.forEach(function (node) {
      const descriptor = moduleGroup(node);
      if (!grouped.has(descriptor.key)) grouped.set(descriptor.key, { descriptor: descriptor, nodes: [] });
      grouped.get(descriptor.key).nodes.push(node);
    });
    state.threeDGroups = Array.from(grouped.values()).sort(function (a, b) { return a.descriptor.key.localeCompare(b.descriptor.key); });
    state.groupByNodeId.clear();
    const groupCount = state.threeDGroups.length;
    state.threeDGroups.forEach(function (group, groupIndex) {
      const angle = (groupIndex / Math.max(1, groupCount)) * Math.PI * 2 - Math.PI / 2;
      const orbit = groupCount <= 1 ? 0 : groupCount <= 4 ? 205 : Math.min(440, 205 + groupCount * 13);
      group.center = { x: Math.cos(angle) * orbit, y: Math.sin(angle) * orbit * 0.58, z: Math.sin(angle * 2 + .5) * Math.min(140, orbit * .35) };
      group.color = GROUP_COLORS[groupIndex % GROUP_COLORS.length];
      group.nodes.sort(function (a, b) { return (a.id === group.descriptor.anchorId ? -1 : b.id === group.descriptor.anchorId ? 1 : typePriority(a.type) - typePriority(b.type)) || a.id.localeCompare(b.id); });
      group.radius = Math.min(168, 46 + Math.sqrt(group.nodes.length) * 13);
      group.nodes.forEach(function (node, index) {
        state.groupByNodeId.set(node.id, group);
        state.threeDPositions.set(node.id, deterministic3dPosition(node, index, group.nodes.length, group));
      });
    });
  }

  function stable3dHash(value) {
    let hash = 2166136261;
    const source = text(value);
    for (let index = 0; index < source.length; index += 1) {
      hash ^= source.charCodeAt(index);
      hash = Math.imul(hash, 16777619);
    }
    return hash >>> 0;
  }

  function deterministic3dPosition(node, index, count, group) {
    const center = group ? group.center : { x: 0, y: 0, z: 0 };
    if (index === 0) return { x: center.x, y: center.y, z: center.z };
    const hash = stable3dHash(node.id);
    const angle = index * 2.399963229728653;
    const radius = 29 + Math.sqrt(index) * 18;
    const depth = ((hash % 1000) / 1000 - 0.5) * Math.min(110, 35 + count * 2);
    return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius * .72, z: center.z + depth };
  }

  function bounded3dGraph(graph) {
    const boundedNodes = graph.nodes
      .slice()
      .sort(function (left, right) {
        if (left.id === state.rootId) return -1;
        if (right.id === state.rootId) return 1;
        const selected = edgeByKey.get(state.selectedEdgeKey);
        if (selected) {
          const leftEndpoint = left.id === selected.source || left.id === selected.target;
          const rightEndpoint = right.id === selected.source || right.id === selected.target;
          if (leftEndpoint !== rightEndpoint) return leftEndpoint ? -1 : 1;
        }
        return left.id.localeCompare(right.id);
      })
      .slice(0, MAX_3D_VISIBLE_NODES);
    const included = new Set(boundedNodes.map(function (node) { return node.id; }));
    const boundedEdges = graph.edges
      .filter(function (edge) { return included.has(edge.source) && included.has(edge.target); })
      .slice()
      .sort(function (left, right) { return left.key.localeCompare(right.key); })
      .slice(0, MAX_3D_VISIBLE_EDGES);
    return { nodes: boundedNodes, edges: boundedEdges, truncated: graph.truncated || boundedNodes.length < graph.nodes.length || boundedEdges.length < graph.edges.length };
  }

  function syncGraphTextSelection() {
    dom.graphTextNodes.querySelectorAll("[data-node-id]").forEach(function (button) {
      const selected = button.dataset.nodeId === state.selectedId && !state.selectedEdgeKey;
      button.classList.toggle("is-selected", selected);
      if (selected) button.setAttribute("aria-current", "true");
      else button.removeAttribute("aria-current");
      if (selected) {
        const group = button.closest("details");
        if (group) group.open = true;
      }
    });
    dom.graphTextEdges.querySelectorAll("[data-edge-key]").forEach(function (button) {
      const selected = button.dataset.edgeKey === state.selectedEdgeKey;
      button.classList.toggle("is-selected", selected);
      if (selected) button.setAttribute("aria-current", "true");
      else button.removeAttribute("aria-current");
      if (selected) {
        const group = button.closest("details");
        if (group) group.open = true;
      }
    });
  }

  function renderGraphTextAlternative(graph) {
    const bounded = bounded3dGraph(graph);
    dom.graphTextSummary.textContent =
      "현재 이웃: 노드 " + formatCount(bounded.nodes.length) + "개, 관계 " +
      formatCount(bounded.edges.length) + "개." +
      (bounded.truncated ? " 표시 한도가 적용되었습니다." : "");
    const nodeGroups = new Map();
    bounded.nodes.forEach(function (node) {
      const label = typeLabel(node.type);
      if (!nodeGroups.has(label)) nodeGroups.set(label, []);
      nodeGroups.get(label).push(node);
    });
    const nodeFragment = document.createDocumentFragment();
    Array.from(nodeGroups.entries()).sort(function (left, right) {
      return left[0].localeCompare(right[0], "ko");
    }).forEach(function (entry) {
      const groupItem = make("div", "graph-text-group-item");
      groupItem.setAttribute("role", "listitem");
      const group = make("details", "graph-text-group");
      group.open = entry[1].some(function (node) { return node.id === state.selectedId && !state.selectedEdgeKey; });
      group.appendChild(make("summary", "", entry[0] + " · " + formatCount(entry[1].length)));
      const list = make("div", "graph-text-list-inner");
      list.setAttribute("role", "list");
      entry[1].forEach(function (node) {
        const item = make("div", "graph-text-item");
        item.setAttribute("role", "listitem");
        const button = make(
          "button",
          "graph-text-button" + (node.id === state.selectedId && !state.selectedEdgeKey ? " is-selected" : ""),
          node.name + " · " + typeLabel(node.type)
        );
        button.type = "button";
        button.dataset.nodeId = node.id;
        if (node.id === state.selectedId && !state.selectedEdgeKey) button.setAttribute("aria-current", "true");
        button.addEventListener("click", function () { selectNode(node.id, false); });
        item.appendChild(button);
        list.appendChild(item);
      });
      append(group, list);
      groupItem.appendChild(group);
      nodeFragment.appendChild(groupItem);
    });
    dom.graphTextNodes.replaceChildren(nodeFragment);
    const edgeGroups = new Map();
    bounded.edges.forEach(function (edge) {
      const label = relationLabel(edge.type);
      if (!edgeGroups.has(label)) edgeGroups.set(label, []);
      edgeGroups.get(label).push(edge);
    });
    const edgeFragment = document.createDocumentFragment();
    Array.from(edgeGroups.entries()).sort(function (left, right) {
      return left[0].localeCompare(right[0], "ko");
    }).forEach(function (entry) {
      const groupItem = make("div", "graph-text-group-item");
      groupItem.setAttribute("role", "listitem");
      const group = make("details", "graph-text-group");
      group.open = entry[1].some(function (edge) { return edge.key === state.selectedEdgeKey; });
      group.appendChild(make("summary", "", entry[0] + " · " + formatCount(entry[1].length)));
      const list = make("div", "graph-text-list-inner");
      list.setAttribute("role", "list");
      entry[1].forEach(function (edge) {
        const source = nodeById.get(edge.source);
        const target = nodeById.get(edge.target);
        const item = make("div", "graph-text-item");
        item.setAttribute("role", "listitem");
        const button = make(
          "button",
          "graph-text-button" + (edge.key === state.selectedEdgeKey ? " is-selected" : ""),
          (source ? source.name : edge.source) + " → " +
            (target ? target.name : edge.target) + " · " + relationLabel(edge.type)
        );
        button.type = "button";
        button.dataset.edgeKey = edge.key;
        if (edge.key === state.selectedEdgeKey) button.setAttribute("aria-current", "true");
        button.addEventListener("click", function () {
          rememberSelection();
          state.selectedEdgeKey = edge.key;
          renderEdgeDetails(edge);
          if (state.cy) {
            state.cy.elements().unselect();
            const edgeId = state.renderedEdgeIdByKey.get(edge.key);
            const element = edgeId ? state.cy.getElementById(edgeId) : null;
            if (element && element.length) element.select();
          }
          syncGraphTextSelection();
          if (state.viewMode === "3d" && state.threeDGraph) draw3dScene(0);
          announce(relationLabel(edge.type) + " 관계 증거 선택됨");
        });
        item.appendChild(button);
        list.appendChild(item);
      });
      append(group, list);
      groupItem.appendChild(group);
      edgeFragment.appendChild(groupItem);
    });
    dom.graphTextEdges.replaceChildren(edgeFragment);
  }

  function ensure3dContext() {
    if (state.threeDContext) return true;
    if (!dom.graph3dCanvas || typeof dom.graph3dCanvas.getContext !== "function") {
      state.threeDAvailable = false;
      return false;
    }
    try {
      state.threeDContext = dom.graph3dCanvas.getContext("2d", { alpha: true });
    } catch (error) {
      state.threeDContext = null;
    }
    state.threeDAvailable = Boolean(state.threeDContext);
    if (state.threeDAvailable) dom.graph3dCanvas.textContent = "";
    return state.threeDAvailable;
  }

  function stop3dFrame() {
    if (state.threeDFrame) window.cancelAnimationFrame(state.threeDFrame);
    state.threeDFrame = 0;
  }

  function resize3dCanvas() {
    const canvas = dom.graph3dCanvas;
    const ratio = Math.max(1, Math.min(2, finiteNumber(window.devicePixelRatio, 1)));
    const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
    const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
    return { width: width, height: height, ratio: ratio };
  }

  function project3d(position, viewport) {
    const x = position.x - state.camera.x;
    const y = position.y - state.camera.y;
    const z = position.z - state.camera.z;
    const yawCos = Math.cos(state.camera.yaw), yawSin = Math.sin(state.camera.yaw);
    const pitchCos = Math.cos(state.camera.pitch), pitchSin = Math.sin(state.camera.pitch);
    const yawX = x * yawCos - z * yawSin;
    const yawZ = x * yawSin + z * yawCos;
    const pitchY = y * pitchCos - yawZ * pitchSin;
    const pitchZ = y * pitchSin + yawZ * pitchCos;
    const perspective = state.camera.distance / Math.max(240, state.camera.distance + pitchZ);
    const baseScale = Math.max(.25, Math.min(1.8, (viewport.width / viewport.ratio) / 1100, (viewport.height / viewport.ratio) / 710));
    const scale = viewport.ratio * state.camera.zoom * perspective * baseScale;
    const offset = !dom.detailsPanel.hidden && viewport.width / viewport.ratio > 900 ? .39 : .5;
    return { x: viewport.width * offset + yawX * scale, y: viewport.height * .51 + pitchY * scale, z: pitchZ, scale: scale, perspective: perspective };
  }

  function nodeCanvasColor(node, forcedForeground) {
    if (forcedForeground) return forcedForeground;
    return ownValue(NODE_COLORS, node.language, node.type === "PolicyLeaf" ? "#f8c15c" : "#ff8790");
  }

  function draw3dScene(timestamp) {
    if (!state.threeDGraph || !ensure3dContext()) return false;
    const startedAt = window.performance && typeof window.performance.now === "function" ? window.performance.now() : Date.now();
    const context = state.threeDContext;
    const viewport = resize3dCanvas();
    const dpr = viewport.ratio;
    context.clearRect(0, 0, viewport.width, viewport.height);
    const forcedColors = window.matchMedia && window.matchMedia("(forced-colors: active)").matches;
    const forcedStyle = forcedColors && typeof window.getComputedStyle === "function" ? window.getComputedStyle(dom.graph3d) : null;
    const foreground = forcedStyle ? forcedStyle.color : "#daf8ff";
    const background = forcedStyle ? forcedStyle.backgroundColor : "#0a1725";
    const focusId = state.threeDHoverNodeId || state.selectedId;
    const connected = new Set(focusId ? [focusId] : []);
    if (focusId) state.threeDGraph.edges.forEach(function (edge) { if (edge.source === focusId) connected.add(edge.target); if (edge.target === focusId) connected.add(edge.source); });

    // Spatial reference rings are scenery, never additional ontology nodes or edges.
    if (!forcedColors) {
      const glow = context.createRadialGradient(viewport.width * .5, viewport.height * .51, 0, viewport.width * .5, viewport.height * .51, viewport.width * .46);
      glow.addColorStop(0, "rgba(56,151,185,.10)"); glow.addColorStop(1, "rgba(4,10,20,0)");
      context.fillStyle = glow; context.fillRect(0, 0, viewport.width, viewport.height);
      [270, 390, 515].forEach(function (radius, index) {
        context.beginPath();
        for (let step = 0; step <= 120; step += 1) {
          const theta = step / 120 * Math.PI * 2;
          const projected = project3d({ x: Math.cos(theta) * radius, y: 170 + index * 15, z: Math.sin(theta) * radius }, viewport);
          if (!step) context.moveTo(projected.x, projected.y); else context.lineTo(projected.x, projected.y);
        }
        context.strokeStyle = "rgba(108,181,214," + (.09 - index * .018) + ")";
        context.lineWidth = dpr * .7; context.stroke();
      });
    }

    const occupied = [];
    const compactLabels = viewport.width / dpr < 760;
    const moduleLabelLimit = compactLabels ? 6 : MAX_MODULE_GROUPS;
    const symbolLabelLimit = compactLabels ? 8 : 42;
    let moduleLabelsDrawn = 0;
    state.threeDGroups.slice().sort(function (a, b) {
      if (compactLabels) {
        const activeA = a.nodes.some(function (node) { return node.id === focusId; });
        const activeB = b.nodes.some(function (node) { return node.id === focusId; });
        return Number(activeB) - Number(activeA) || project3d(a.center, viewport).z - project3d(b.center, viewport).z || a.descriptor.key.localeCompare(b.descriptor.key);
      }
      return b.center.z - a.center.z;
    }).forEach(function (group) {
      const point = project3d(group.center, viewport);
      const radius = Math.max(26 * dpr, group.radius * point.scale);
      const active = group.nodes.some(function (node) { return node.id === focusId; });
      context.save();
      context.globalAlpha = focusId && !active ? .38 : 1;
      if (!forcedColors) {
        const halo = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius * 1.55);
        halo.addColorStop(0, group.color + (active ? "13" : "0a")); halo.addColorStop(1, group.color + "00");
        context.fillStyle = halo; context.fillRect(point.x - radius * 1.55, point.y - radius * 1.55, radius * 3.1, radius * 3.1);
      }
      context.beginPath();
      context.ellipse(point.x, point.y, radius * 1.14, radius * .80, -.10, 0, Math.PI * 2);
      context.strokeStyle = forcedColors ? foreground : group.color + (active ? "60" : "25");
      context.lineWidth = dpr * .8;
      context.setLineDash([3 * dpr, 7 * dpr]); context.stroke(); context.setLineDash([]);
      context.font = "500 " + (10 * dpr) + "px ui-monospace, SFMono-Regular, monospace";
      context.textAlign = "center";
      context.fillStyle = forcedColors ? foreground : group.color + "c9";
      const label = shortLabel(group.descriptor.label, compactLabels ? 20 : 37);
      const captionWidth = context.measureText(label).width + 12 * dpr;
      const captionY = point.y - radius * .88 - 14 * dpr;
      const captionBox = { x: point.x - captionWidth / 2, y: captionY - 11 * dpr, w: captionWidth, h: 29 * dpr };
      const overlaps = occupied.some(function (other) { return captionBox.x < other.x + other.w + 6 * dpr && captionBox.x + captionBox.w + 6 * dpr > other.x && captionBox.y < other.y + other.h + 6 * dpr && captionBox.y + captionBox.h + 6 * dpr > other.y; });
      const inViewport = captionBox.x >= 8 * dpr && captionBox.x + captionBox.w <= viewport.width - 8 * dpr && captionBox.y >= 130 * dpr && captionBox.y + captionBox.h <= viewport.height - 115 * dpr;
      if (!compactLabels || (moduleLabelsDrawn < moduleLabelLimit && !overlaps && inViewport)) {
        context.fillText(label, point.x, captionY);
        occupied.push(captionBox);
        moduleLabelsDrawn += 1;
        context.font = (8 * dpr) + "px system-ui, sans-serif";
        context.fillStyle = forcedColors ? foreground : "#7599ad";
        context.fillText(group.descriptor.kind + " · " + group.nodes.length, point.x, captionY + 13 * dpr);
      }
      context.restore();
    });

    state.threeDProjectedNodes = state.threeDGraph.nodes.map(function (node, index) {
      const position = state.threeDPositions.get(node.id) || deterministic3dPosition(node, index, state.threeDGraph.nodes.length);
      return Object.assign({ node: node }, project3d(position, viewport));
    });
    const projectedById = new Map(state.threeDProjectedNodes.map(function (item) { return [item.node.id, item]; }));
    state.threeDProjectedEdges = state.threeDGraph.edges.map(function (edge) { return { edge: edge, source: projectedById.get(edge.source), target: projectedById.get(edge.target) }; }).filter(function (item) { return item.source && item.target; });
    state.threeDProjectedEdges.sort(function (a, b) { return (b.source.z + b.target.z) - (a.source.z + a.target.z); });
    state.threeDProjectedEdges.forEach(function (item) {
      const selected = item.edge.key === state.selectedEdgeKey || item.edge.key === state.threeDHoverEdgeKey;
      const adjacent = item.edge.source === focusId || item.edge.target === focusId;
      const alpha = selected ? 1 : focusId ? adjacent ? .70 : .065 : .23;
      const group = state.groupByNodeId.get(item.edge.source);
      const color = group ? group.color : "#8fcbeb";
      context.save();
      context.globalAlpha = forcedColors ? 1 : alpha;
      context.strokeStyle = forcedColors ? foreground : selected ? "#b0fff1" : color;
      context.lineWidth = (selected ? 2.3 : adjacent ? 1.5 : .75) * dpr;
      if (selected && !forcedColors) { context.shadowColor = color; context.shadowBlur = 9 * dpr; }
      const dx = item.target.x - item.source.x, dy = item.target.y - item.source.y;
      const length = Math.max(1, Math.hypot(dx, dy));
      const curve = Math.min(18 * dpr, length * .055);
      item.control = { x: (item.source.x + item.target.x) / 2 - dy / length * curve, y: (item.source.y + item.target.y) / 2 + dx / length * curve };
      context.beginPath(); context.moveTo(item.source.x, item.source.y); context.quadraticCurveTo(item.control.x, item.control.y, item.target.x, item.target.y); context.stroke();
      if (selected || adjacent || state.threeDGraph.edges.length < 55) {
        const t = .78;
        const ax = (1-t)*(1-t)*item.source.x + 2*(1-t)*t*item.control.x + t*t*item.target.x;
        const ay = (1-t)*(1-t)*item.source.y + 2*(1-t)*t*item.control.y + t*t*item.target.y;
        const angle = Math.atan2(item.target.y-item.control.y, item.target.x-item.control.x);
        const size = (selected ? 5 : 3) * dpr;
        context.beginPath(); context.moveTo(ax,ay); context.lineTo(ax-Math.cos(angle-.42)*size,ay-Math.sin(angle-.42)*size); context.lineTo(ax-Math.cos(angle+.42)*size,ay-Math.sin(angle+.42)*size); context.closePath(); context.fillStyle=context.strokeStyle; context.fill();
      }
      if (selected) {
        context.font = "500 " + 10*dpr + "px system-ui, sans-serif"; context.textAlign="center";
        const caption=relationLabel(item.edge.type); const measured=context.measureText(caption).width;
        context.fillStyle=background; context.fillRect(item.control.x-measured/2-5*dpr,item.control.y-13*dpr,measured+10*dpr,18*dpr);
        context.fillStyle=foreground; context.fillText(caption,item.control.x,item.control.y);
      }
      context.restore();
    });

    const focusedNode = state.threeDGraph.nodes[state.threeDFocusedIndex];
    state.threeDProjectedNodes.sort(function (a, b) { return b.z - a.z; });
    state.threeDProjectedNodes.forEach(function (item) {
      const group = state.groupByNodeId.get(item.node.id);
      const anchor = group && group.nodes[0].id === item.node.id;
      const selected = item.node.id === state.selectedId;
      const hovered = item.node.id === state.threeDHoverNodeId;
      const focused = document.activeElement === dom.graph3dCanvas && focusedNode && focusedNode.id === item.node.id;
      const color = forcedColors ? foreground : group ? group.color : "#91dce7";
      const emphasis = selected || hovered || focused;
      const radius = Math.max(3.4 * dpr, Math.min(13*dpr, (anchor ? 8 : 4.7) * item.scale));
      context.save();
      context.globalAlpha = forcedColors ? 1 : focusId && !connected.has(item.node.id) ? .25 : Math.max(.45, Math.min(1,item.perspective));
      if (!forcedColors) { context.shadowColor=color; context.shadowBlur=(emphasis?22:anchor?12:6)*dpr; }
      if (emphasis) {
        context.beginPath(); context.arc(item.x,item.y,radius+7*dpr,0,Math.PI*2); context.strokeStyle=color; context.lineWidth=dpr; context.stroke();
        context.beginPath(); context.arc(item.x,item.y,radius+12*dpr,-.7,.5); context.arc(item.x,item.y,radius+12*dpr,2.5,3.6); context.strokeStyle=forcedColors?foreground:color+"80"; context.stroke();
      }
      context.beginPath();
      if (anchor || ["PolicyLeaf","RuntimeBranch"].includes(item.node.type)) {
        const sides=anchor?6:4;
        for(let vertex=0;vertex<sides;vertex+=1){ const theta=vertex/sides*Math.PI*2-Math.PI/2; const x=item.x+Math.cos(theta)*radius,y=item.y+Math.sin(theta)*radius; if(!vertex)context.moveTo(x,y);else context.lineTo(x,y); }
        context.closePath();
      } else context.arc(item.x,item.y,radius,0,Math.PI*2);
      if (!forcedColors) {
        const sphere=context.createRadialGradient(item.x-radius*.3,item.y-radius*.4,0,item.x,item.y,radius);
        sphere.addColorStop(0,"#e4ffff"); sphere.addColorStop(.32,color); sphere.addColorStop(1,color+"64"); context.fillStyle=sphere;
      } else context.fillStyle=foreground;
      context.fill(); context.lineWidth=(emphasis?1.7:.7)*dpr; context.strokeStyle=forcedColors?background:color+"db"; context.stroke(); context.restore();
      item.hitRadius=Math.max(12*dpr,radius+5*dpr);
      item.labelPriority=emphasis?0:anchor?1:connected.has(item.node.id)?2:3;
    });
    // Labels retain deterministic priority and never overlap one another.
    state.threeDProjectedNodes.slice().sort(function(a,b){return a.labelPriority-b.labelPriority || a.z-b.z || a.node.id.localeCompare(b.node.id);}).forEach(function(item,index){
      if(index>=symbolLabelLimit && item.labelPriority>0)return;
      if(state.threeDGraph.nodes.length>55 && item.labelPriority===3)return;
      const caption=shortLabel(item.node.name,compactLabels?(item.labelPriority===0?28:18):(item.labelPriority===0?44:28));
      const fontSize=(item.labelPriority===0?12:10)*dpr;
      context.font=(item.labelPriority===0?"600 ":"400 ")+fontSize+"px system-ui, sans-serif";
      const width=context.measureText(caption).width+12*dpr;
      const x=item.x-width/2,y=item.y+item.hitRadius+5*dpr;
      const box={x:x,y:y-10*dpr,w:width,h:18*dpr};
      if(x<8*dpr||x+width>viewport.width-8*dpr||y>viewport.height-90*dpr)return;
      if(item.labelPriority>0&&occupied.some(function(other){return box.x<other.x+other.w&&box.x+box.w>other.x&&box.y<other.y+other.h&&box.y+box.h>other.y;}))return;
      occupied.push(box);
      context.fillStyle=forcedColors?background:"rgba(6,17,29,.86)"; context.fillRect(box.x,box.y,box.w,box.h);
      context.fillStyle=forcedColors?foreground:item.labelPriority===0?"#e4fffa":item.labelPriority===3?"#86a5b7":"#b8d2e0";
      context.textAlign="center"; context.fillText(caption,item.x,y+2*dpr);
    });
    state.threeDLastRenderMs=Math.max(0,(window.performance&&typeof window.performance.now==="function"?window.performance.now():Date.now())-startedAt);
    const statusText=state.threeDGroups.length+"개 모듈 / 폴더 · "+state.threeDGraph.nodes.length+"개 심볼";
    if(dom.graph3dStatus.textContent!==statusText)dom.graph3dStatus.textContent=statusText;
    const summary=statusText+", 관계 "+state.threeDGraph.edges.length+"개. "+(focusedNode?"키보드 초점: "+focusedNode.name+". ":"")+"목록 보기에서 같은 항목을 선택할 수 있습니다.";
    if(dom.graph3dSummary.textContent!==summary)dom.graph3dSummary.textContent=summary;
    return true;
  }

  function schedule3dFrame() {
    stop3dFrame();
    if (state.viewMode !== "3d" || document.visibilityState === "hidden" || dom.graphView.hidden || state.activeLens === "changes") return;
    state.threeDFrame = window.requestAnimationFrame(function tick(timestamp) {
      state.threeDFrame = 0;
      if (state.viewMode !== "3d" || document.visibilityState === "hidden" || dom.graphView.hidden || state.activeLens === "changes") return;
      const frameInterval = state.threeDLastRenderMs > THREE_D_FRAME_BUDGET_MS ? THREE_D_SLOW_FRAME_INTERVAL_MS : THREE_D_FRAME_INTERVAL_MS;
      const elapsed = timestamp - state.threeDLastFrameAt;
      if (elapsed >= frameInterval) {
        if (state.cameraTransition) {
          const transition = state.cameraTransition;
          if (!transition.startedAt) transition.startedAt = timestamp;
          const fraction = Math.min(1, (timestamp - transition.startedAt) / 540);
          const eased = 1 - Math.pow(1 - fraction, 3);
          ["x", "y", "z", "zoom"].forEach(function (key) { state.camera[key] = transition.from[key] + (transition.to[key] - transition.from[key]) * eased; });
          if (fraction >= 1) state.cameraTransition = null;
        } else if (state.motionEnabled && !state.threeDDragging && !state.threeDHoverNodeId ) state.camera.yaw += .000045 * Math.min(66, elapsed || 16);
        state.threeDLastFrameAt = timestamp;
        draw3dScene(timestamp);
      }
      if (state.cameraTransition || state.motionEnabled) state.threeDFrame = window.requestAnimationFrame(tick);
    });
  }

  function focus3dCamera(nodeId) {
    const position = state.threeDPositions.get(nodeId);
    if (!position || state.viewMode !== "3d") return;
    state.cameraFocusId = nodeId;
    const target = { x: position.x, y: position.y, z: position.z, zoom: Math.max(1.05, Math.min(1.4, state.camera.zoom)) };
    if (reducedMotionQuery.matches) { Object.assign(state.camera, target); state.cameraTransition = null; draw3dScene(0); }
    else { state.cameraTransition = { from: Object.assign({}, state.camera), to: target, startedAt: 0 }; schedule3dFrame(); }
  }

  function renderGraph3d(graph) {
    if (!ensure3dContext()) return false;
    state.threeDGraph = bounded3dGraph(graph);
    const signature = state.threeDGraph.nodes.map(function(node){return node.id;}).sort().join("\u0000");
    if (signature !== state.graphSignature) { state.graphSignature = signature; state.threeDPositions.clear(); buildModuleLayout(state.threeDGraph); }
    state.threeDFocusedIndex = Math.max(0, state.threeDGraph.nodes.findIndex(function (node) { return node.id === state.selectedId; }));
    draw3dScene(0);
    if (state.selectedId) focus3dCamera(state.selectedId); else schedule3dFrame();
    return true;
  }

  function updateMotionControl() {
    dom.motionToggle.setAttribute("aria-pressed", state.motionEnabled ? "true" : "false");
    dom.motionToggle.replaceChildren(make("span", "", state.motionEnabled ? "◌ 회전" : "◌ 정지"));
    dom.motionToggle.setAttribute("aria-label", "공간 회전 " + (state.motionEnabled ? "켜짐" : "꺼짐"));
  }

  function setViewMode(mode, message, rerender, silent) {
    const requested = mode === "3d" ? "3d" : "2d";
    if (requested === "3d" && !ensure3dContext()) {
      state.threeDAvailable = false;
      state.viewMode = "2d";
      message = "3D Canvas를 사용할 수 없어 2D 구조 보기로 돌아왔습니다.";
    } else {
      state.viewMode = requested;
    }
    const is3d = state.viewMode === "3d";
    dom.graphView.dataset.viewMode = state.viewMode;
    setHidden(dom.graph, is3d);
    setHidden(dom.graph3d, !is3d);
    dom.viewMode2d.classList.toggle("is-active", !is3d);
    dom.viewMode3d.classList.toggle("is-active", is3d);
    dom.viewMode2d.setAttribute("aria-pressed", is3d ? "false" : "true");
    dom.viewMode3d.setAttribute("aria-pressed", is3d ? "true" : "false");
    if (!is3d) stop3dFrame();
    if (rerender !== false && state.activeLens !== "overview" && state.activeLens !== "changes") renderGraph(false);
    if (!silent) announce(message || (is3d ? "3D 공간 보기로 전환했습니다." : "2D 구조 보기로 전환했습니다."));
  }

  function reset3dCamera() {
    state.camera = { yaw: -0.38, pitch: -0.16, zoom: 1, distance: 980, x: 0, y: 0, z: 0 };
    state.cameraTransition = null;
    draw3dScene(0);
    schedule3dFrame();
  }

  function hit3dNode(clientX, clientY) {
    const rect = dom.graph3dCanvas.getBoundingClientRect();
    const scaleX = dom.graph3dCanvas.width / Math.max(1, rect.width);
    const scaleY = dom.graph3dCanvas.height / Math.max(1, rect.height);
    const x = (clientX - rect.left) * scaleX;
    const y = (clientY - rect.top) * scaleY;
    let best = null;
    let bestDistance = Number.POSITIVE_INFINITY;
    state.threeDProjectedNodes.forEach(function (item) {
      const distance = Math.hypot(item.x - x, item.y - y);
      if (distance <= item.hitRadius && distance < bestDistance) {
        best = item;
        bestDistance = distance;
      }
    });
    return best;
  }

  function hit3dEdge(clientX, clientY) {
    const rect = dom.graph3dCanvas.getBoundingClientRect();
    const ratio = dom.graph3dCanvas.width / Math.max(1, rect.width);
    const x = (clientX - rect.left) * ratio, y = (clientY - rect.top) * ratio;
    let best = null, bestDistance = 7 * ratio;
    state.threeDProjectedEdges.forEach(function (item) {
      if (!item.control) return;
      for (let step = 1; step < 16; step += 1) {
        const t = step / 16, u = 1 - t;
        const px = u*u*item.source.x + 2*u*t*item.control.x + t*t*item.target.x;
        const py = u*u*item.source.y + 2*u*t*item.control.y + t*t*item.target.y;
        const distance = Math.hypot(x-px,y-py);
        if (distance < bestDistance) { best = item.edge; bestDistance = distance; }
      }
    });
    return best;
  }

  function selectFocused3dNode() {
    if (!state.threeDGraph || !state.threeDGraph.nodes.length) return;
    const node = state.threeDGraph.nodes[state.threeDFocusedIndex];
    if (node) {
      selectNode(node.id, false);
      draw3dScene(0);
    }
  }

  function renderGraph(announceResult) {
    if (state.activeLens === "changes") return;
    if (!state.atlasOverview && (!state.rootId || !nodeById.has(state.rootId))) state.rootId = defaultSeed(state.activeLens);
    const graph = bounded3dGraph(
      state.atlasOverview && state.activeLens === "architecture" ? atlasGraph() : neighborhood(state.rootId, state.activeLens, state.depth, state.direction)
    );
    if (state.selectedEdgeKey && !graph.edges.some(function (edge) { return edge.key === state.selectedEdgeKey; })) {
      state.selectedEdgeKey = "";
      if (state.selectedId && nodeById.has(state.selectedId)) renderDetails(nodeById.get(state.selectedId));
    }
    const token = state.renderToken + 1;
    state.renderToken = token;
    setHidden(dom.graphEmpty, graph.nodes.length > 0);
    dom.graph.setAttribute(
      "aria-label",
      typeLabel(nodeById.get(state.rootId) ? nodeById.get(state.rootId).type : "") +
        " 중심의 정적 관계 그래프. 노드 " +
        graph.nodes.length +
        "개, 관계 " +
        graph.edges.length +
        "개."
    );
    dom.graphNote.textContent =
      formatCount(graph.nodes.length) +
      "개 심볼 · " +
      formatCount(graph.edges.length) +
      "개 관계" +
      (state.atlasOverview ? "" : " · " + state.depth + "단계") +
      (graph.truncated
        ? " · 일부 표시 (전체 " + formatCount(nodes.length) + "개 심볼)"
        : "");

    renderGraphTextAlternative(graph);

    if (!graph.nodes.length) {
      stop3dFrame();
      state.threeDGraph = null;
      if (state.threeDContext) state.threeDContext.clearRect(0, 0, dom.graph3dCanvas.width, dom.graph3dCanvas.height);
      if (state.cy) state.cy.elements().remove();
      if (announceResult !== false) announce("표시할 관계가 없습니다.");
      return;
    }
    let fallbackAnnouncement = "";
    if (state.viewMode === "3d") {
      if (renderGraph3d(graph)) {
        if (announceResult !== false) announce("3D 노드 " + graph.nodes.length + "개와 관계 " + graph.edges.length + "개를 표시했습니다.");
        return;
      }
      fallbackAnnouncement = "3D Canvas를 사용할 수 없어 2D 구조 보기로 돌아왔습니다.";
      setViewMode("2d", fallbackAnnouncement, false, true);
    }
    if (!ensureCytoscape()) {
      setHidden(dom.graphEmpty, false);
      dom.graphEmpty.replaceChildren(
        make("strong", "", "그래프 엔진을 불러오지 못했습니다."),
        make("span", "", "검색 결과와 상세 패널로 전체 온톨로지를 계속 탐색할 수 있습니다.")
      );
      dom.graphNote.textContent = "Cytoscape를 사용할 수 없음";
      if (announceResult !== false) announce("그래프 엔진을 불러오지 못했습니다.");
      return;
    }

    state.cy.startBatch();
    state.cy.elements().remove();
    state.cy.add(graphElements(graph));
    state.cy.endBatch();
    const selectedEdgeId = state.renderedEdgeIdByKey.get(state.selectedEdgeKey);
    const selectedEdge = selectedEdgeId ? state.cy.getElementById(selectedEdgeId) : null;
    if (selectedEdge && selectedEdge.length) {
      selectedEdge.select();
    } else {
      if (state.selectedEdgeKey) {
        state.selectedEdgeKey = "";
        if (state.selectedId && nodeById.has(state.selectedId)) {
          renderDetails(nodeById.get(state.selectedId));
        }
      }
      const selected = state.cy.getElementById(state.selectedId);
      if (selected && selected.length) selected.select();
    }
    window.requestAnimationFrame(function () {
      if (!state.cy || token !== state.renderToken) return;
      state.cy.resize();
      layoutWithElk(graph, token);
    });
    if (announceResult !== false) {
      announce(fallbackAnnouncement || "노드 " + graph.nodes.length + "개와 관계 " + graph.edges.length + "개를 표시했습니다.");
    }
  }

  function changeCount(name) {
    return finiteNumber(objectOrEmpty(changes.counts)[name], arrayOrEmpty(changes[name]).length);
  }

  function changeNodeButton(item, mode) {
    const node = objectOrEmpty(item);
    const button = make("button", "change-item");
    button.type = "button";
    const nodeId = text(node.id);
    append(
      button,
      make(
        "strong",
        mode === "added" ? "change-added" : mode === "modified" ? "change-modified" : "change-removed",
        text(node.name, nodeId || "이름 없음")
      ),
      make("span", "", typeLabel(text(node.type, "Unknown")))
    );
    button.addEventListener("click", function () {
      if (nodeId && nodeById.has(nodeId)) {
        switchLens("explore", nodeId);
      } else {
        renderRemovedDetails(node);
        announce("삭제된 심볼의 이전 스냅샷 정보를 표시했습니다.");
      }
    });
    return button;
  }

  function edgeDescription(item) {
    const edge = objectOrEmpty(item);
    const source = nodeById.get(text(edge.source));
    const target = nodeById.get(text(edge.target));
    return (
      (source ? source.name : shortLabel(text(edge.source), 24)) +
      " → " +
      (target ? target.name : shortLabel(text(edge.target), 24))
    );
  }

  function changeEdgeButton(item, mode) {
    const edge = objectOrEmpty(item);
    const button = make("button", "change-item");
    button.type = "button";
    append(
      button,
      make("strong", mode === "added" ? "change-added" : mode === "modified" ? "change-modified" : "change-removed", edgeDescription(edge)),
      make("span", "", relationLabel(text(edge.type)))
    );
    const source = text(edge.source);
    const target = text(edge.target);
    const available = nodeById.has(source) ? source : nodeById.has(target) ? target : "";
    button.addEventListener("click", function () {
      const key = source + "\u0000" + text(edge.type) + "\u0000" + target;
      const current = edgeByKey.get(key);
      if (mode !== "removed" && current && available) {
        switchLens("architecture", available);
        state.selectedEdgeKey = key;
        renderEdgeDetails(current);
        if (mode === "modified") {
          const previous = make("section", "details-section");
          previous.appendChild(make("h4", "", "이전 스냅샷 근거"));
          arrayOrEmpty(edge.previousEvidence).forEach(function (item) { previous.appendChild(renderEdgeEvidenceCard(item)); });
          if (!arrayOrEmpty(edge.previousEvidence).length) previous.appendChild(make("p", "details-subtitle", "이전 구조화된 근거 없음"));
          dom.detailsContent.appendChild(previous);
        }
        syncGraphTextSelection();
        draw3dScene(0);
      } else {
        setHidden(dom.detailsPanel, false);
        dom.detailsContent.replaceChildren(make("span", "relation-chip change-removed", "삭제된 관계"), make("h3", "", edgeDescription(edge)), make("p", "details-subtitle", relationLabel(text(edge.type)) + " · 이전 스냅샷"));
        arrayOrEmpty(edge.evidence).forEach(function (item) { dom.detailsContent.appendChild(renderEdgeEvidenceCard(item)); });
      }
    });
    return button;
  }

  function changeList(title, items, mode, kind) {
    const card = make("article", "surface-card change-list");
    const heading = make("div", "card-heading");
    append(
      heading,
      append(
        make("div", ""),
        make("span", "section-kicker", mode === "added" ? "추가" : mode === "modified" ? "수정" : "삭제"),
        make("h3", "", title)
      ),
      make("span", "count-pill", formatCount(items.length))
    );
    const list = make("div", "change-items");
    items.forEach(function (item) {
      list.appendChild(kind === "node" ? changeNodeButton(item, mode) : changeEdgeButton(item, mode));
    });
    if (!items.length) list.appendChild(make("div", "empty-results", "해당 변경이 없습니다."));
    append(card, heading, list);
    return card;
  }

  function renderChanges() {
    const hasComparison =
      changes.available !== false && Boolean(changes.beforeSnapshotId || changes.previousSnapshotId);
    if (!hasComparison) {
      const card = make("article", "surface-card");
      append(
        card,
        make("div", "section-kicker", "첫 스냅샷"),
        make("h3", "", "비교할 이전 스냅샷이 없습니다"),
        make("p", "view-description", "다음 스냅샷에서 심볼과 관계의 추가·수정·삭제를 비교합니다.")
      );
      dom.changesView.replaceChildren(card);
      return;
    }
    const summary = make("div", "change-summary");
    summary.appendChild(metricCard("추가 심볼", formatCount(changeCount("nodesAdded")), "현재 스냅샷에 새로 등장"));
    summary.appendChild(metricCard("삭제 심볼", formatCount(changeCount("nodesRemoved")), "이전 스냅샷에서 사라짐"));
    summary.appendChild(metricCard("수정 심볼", formatCount(changeCount("nodesModified")), "같은 ID의 속성이 변경됨"));
    summary.appendChild(metricCard("추가 관계", formatCount(changeCount("edgesAdded")), "새로운 정적 연결"));
    summary.appendChild(metricCard("삭제 관계", formatCount(changeCount("edgesRemoved")), ""));
    summary.appendChild(metricCard("근거 변경", formatCount(changeCount("edgesModified")), "같은 관계의 근거 변경"));
    const context = make("article", "surface-card change-context");
    append(
      context,
      make("div", "section-kicker", text(changes.basis, "정적 구조 비교")),
      make("h3", "", shortLabel(text(changes.beforeSnapshotId, "이전") + " → " + text(changes.afterSnapshotId, "현재"), 110)),
      make(
        "p",
        "view-description",
        "정적 스냅샷 비교" + (changes.truncated ? " · 목록 일부 표시" : "")
      )
    );
    const nodeGrid = make("div", "change-list-grid");
    append(
      nodeGrid,
      changeList("추가된 심볼", arrayOrEmpty(changes.nodesAdded), "added", "node"),
      changeList("삭제된 심볼", arrayOrEmpty(changes.nodesRemoved), "removed", "node"),
      changeList("수정된 심볼", arrayOrEmpty(changes.nodesModified), "modified", "node")
    );
    const edgeGrid = make("div", "change-list-grid");
    append(
      edgeGrid,
      changeList("추가된 관계", arrayOrEmpty(changes.edgesAdded), "added", "edge"),
      changeList("삭제된 관계", arrayOrEmpty(changes.edgesRemoved), "removed", "edge"),
      changeList("근거가 바뀐 관계", arrayOrEmpty(changes.edgesModified), "modified", "edge")
    );
    dom.changesView.replaceChildren(summary, context, nodeGrid, edgeGrid);
  }

  function updateLensButtons(lens) {
    dom.lensNav.querySelectorAll("[data-lens]").forEach(function (button) {
      const active = button.dataset.lens === lens;
      button.classList.toggle("is-active", active);
      button.setAttribute("aria-current", active ? "page" : "false");
    });
  }

  function switchLens(lens, preferredRoot, preserveState) {
    lens = LENS_ALIASES[lens] || lens;
    if (!LENS_COPY[lens]) return;
    if (!preserveState && (lens !== state.activeLens || preferredRoot)) rememberSelection();
    const changedLens = state.activeLens !== lens;
    state.activeLens = lens;
    updateLensButtons(lens);
    setHidden(dom.overviewView, true);
    dom.qualityToggle.setAttribute("aria-expanded", "false");
    setHidden(dom.searchPanel, true);
    dom.searchInput.setAttribute("aria-expanded", "false");
    const graphMode = lens !== "changes";
    if (!graphMode) { state.renderToken += 1; stop3dFrame(); setHidden(dom.detailsPanel, true); }
    setHidden(dom.graphView, !graphMode);
    setHidden(dom.changesView, graphMode);
    setHidden(dom.graphToolbar, !graphMode);
    setHidden(dom.graphTextAlternative, !graphMode);
    if (lens === "changes") renderChanges();
    if (preferredRoot && nodeById.has(preferredRoot)) {
      state.rootId = preferredRoot; state.selectedId = preferredRoot; state.selectedEdgeKey = ""; state.selectedEvidenceId = ""; state.atlasOverview = false;
    } else if (lens === "architecture" && changedLens && !preserveState) {
      state.atlasOverview = true; state.rootId = ""; state.selectedId = ""; state.selectedEdgeKey = ""; state.selectedEvidenceId = ""; setHidden(dom.detailsPanel, true); reset3dCamera();
    }
    if (lens === "impact") {
      state.atlasOverview = false;
      if (changedLens && !preserveState) { state.direction = "incoming"; dom.directionSelect.value = "incoming"; }
      if (!state.rootId || !nodeById.has(state.rootId)) state.rootId = state.selectedId || defaultSeed("impact");
      if (!state.selectedId) state.selectedId = state.rootId;
    }
    if (graphMode) {
      if (state.selectedId && preferredRoot) renderDetails(nodeById.get(state.selectedId));
      renderSearchResults();
      window.requestAnimationFrame(function () { renderGraph(); });
    }
    updateViewHeading();
    updateSelectionLink();
    announce(LENS_COPY[lens].title + " 보기");
  }

  function initializeMeta() {
    const repositoryName = text(meta.repositoryName, "이름 없는 저장소");
    dom.repositoryName.textContent = repositoryName;
    dom.repositoryName.title = repositoryName;
    dom.snapshotBadge.textContent = meta.generatedAt ? formatDate(meta.generatedAt) : "스냅샷";
    dom.snapshotBadge.title = "생성: " + formatDate(meta.generatedAt) + " · 생성기 " + text(meta.generatorVersion, "unknown");
    dom.evidenceBadge.textContent = "정적 소스";
    const warningCount = finiteNumber(statistics.warnings, warnings.length);
    dom.warningBadge.textContent = warningCount ? "분석 경고 " + formatCount(warningCount) : "분석 정보";
    dom.warningBadge.classList.toggle("status-badge--warning", warningCount > 0);
    dom.copyLink.disabled = !meta.snapshotId;
    document.title = "Code Ontology — " + repositoryName;
  }

  function bindEvents() {
    dom.viewBack.addEventListener("click", goBack);
    dom.closeDetails.addEventListener("click", function () { setHidden(dom.detailsPanel, true); dom.graph3dCanvas.focus(); draw3dScene(0); });
    dom.closeSearch.addEventListener("click", function () { setHidden(dom.searchPanel, true); dom.searchInput.setAttribute("aria-expanded", "false"); dom.graph3dCanvas.focus(); });
    dom.searchInput.addEventListener("focus", function () { setHidden(dom.searchPanel, false); dom.searchInput.setAttribute("aria-expanded", "true"); });
    dom.qualityToggle.addEventListener("click", function () { const show = dom.overviewView.hidden; setHidden(dom.overviewView, !show); dom.qualityToggle.setAttribute("aria-expanded", show ? "true" : "false"); });
    dom.qualityClose.addEventListener("click", function () { setHidden(dom.overviewView, true); dom.qualityToggle.setAttribute("aria-expanded", "false"); dom.qualityToggle.focus(); });
    dom.copyLink.addEventListener("click", function () {
      let field = dom.detailsPanel.querySelector(".selection-link");
      if (!field) { field = make("input", "selection-link"); field.type = "text"; field.readOnly = true; field.setAttribute("aria-label", "현재 선택의 스냅샷 링크"); dom.detailsPanel.appendChild(field); }
      field.value = selectionUrl().href; field.focus(); field.select(); announce("현재 선택 링크를 복사할 수 있습니다.");
    });

    dom.lensNav.addEventListener("click", function (event) {
      const button = event.target.closest("[data-lens]");
      if (button) switchLens(button.dataset.lens);
    });
    dom.globalSearch.addEventListener("submit", function (event) {
      event.preventDefault();
      const selected = state.searchMatches[state.activeSearchIndex];
      if (selected) focusAsRoot(selected.id);
    });
    dom.searchInput.setAttribute("aria-controls", "search-results");
    dom.searchInput.setAttribute("aria-autocomplete", "list");
    dom.searchInput.addEventListener("input", function () {
      setHidden(dom.searchPanel, false); dom.searchInput.setAttribute("aria-expanded", "true");
      runSearch();
    });
    dom.searchInput.addEventListener("keydown", function (event) {
      const visibleCount = Math.min(state.searchMatches.length, state.searchVisibleLimit);
      if (event.key === "ArrowDown" && visibleCount) {
        event.preventDefault();
        state.activeSearchIndex = (state.activeSearchIndex + 1 + visibleCount) % visibleCount;
        syncActiveSearchOption(true);
      } else if (event.key === "ArrowUp" && visibleCount) {
        event.preventDefault();
        state.activeSearchIndex = (state.activeSearchIndex - 1 + visibleCount) % visibleCount;
        syncActiveSearchOption(true);
      } else if (event.key === "Escape") {
        if (dom.searchInput.value) {
          dom.searchInput.value = "";
          runSearch();
        } else {
          setHidden(dom.searchPanel, true); dom.searchInput.setAttribute("aria-expanded", "false"); dom.searchInput.blur();
        }
      }
    });
    dom.languageFilter.addEventListener("change", runSearch);
    dom.typeFilter.addEventListener("change", runSearch);
    dom.depthSelect.addEventListener("change", function () {
      state.depth = Math.max(1, Math.min(3, finiteNumber(dom.depthSelect.value, 2)));
      renderGraph();
    });
    dom.directionSelect.addEventListener("change", function () {
      state.direction = ["both", "incoming", "outgoing"].includes(dom.directionSelect.value)
        ? dom.directionSelect.value
        : "both";
      updateViewHeading();
      renderGraph();
    });
    dom.viewMode2d.addEventListener("click", function () {
      setViewMode("2d");
    });
    dom.viewMode3d.addEventListener("click", function () {
      setViewMode("3d");
    });
    dom.motionToggle.addEventListener("click", function () {
      state.motionEnabled = !state.motionEnabled;
      updateMotionControl();
      draw3dScene(0);
      schedule3dFrame();
      announce(state.motionEnabled ? "3D 자동 움직임을 켰습니다." : "3D 자동 움직임을 멈췄습니다.");
    });
    dom.zoomIn.addEventListener("click", function () {
      if (state.viewMode === "3d") {
        state.camera.zoom = Math.min(2.4, state.camera.zoom * 1.16);
        draw3dScene(0);
      } else if (state.cy) {
        state.cy.zoom({ level: Math.min(state.cy.maxZoom(), state.cy.zoom() * 1.2), renderedPosition: { x: dom.graph.clientWidth / 2, y: dom.graph.clientHeight / 2 } });
      }
    });
    dom.zoomOut.addEventListener("click", function () {
      if (state.viewMode === "3d") {
        state.camera.zoom = Math.max(0.35, state.camera.zoom / 1.16);
        draw3dScene(0);
      } else if (state.cy) {
        state.cy.zoom({ level: Math.max(state.cy.minZoom(), state.cy.zoom() / 1.2), renderedPosition: { x: dom.graph.clientWidth / 2, y: dom.graph.clientHeight / 2 } });
      }
    });
    dom.fitGraph.addEventListener("click", function () {
      if (state.viewMode === "3d") reset3dCamera();
      else if (state.cy) state.cy.fit(state.cy.elements(), 46);
    });
    dom.resetView.addEventListener("click", function () {
      rememberSelection();
      state.depth = 2; state.direction = "both"; dom.depthSelect.value = "2"; dom.directionSelect.value = "both";
      state.rootId = ""; state.selectedId = ""; state.selectedEdgeKey = ""; state.selectedEvidenceId = ""; state.atlasOverview = true;
      setHidden(dom.detailsPanel, true); reset3dCamera(); switchLens("architecture", "", true);
    });
    dom.graph3dCanvas.addEventListener("pointerdown", function (event) {
      state.cameraTransition = null;
      state.threeDDragging = true;
      state.threeDDragDistance = 0;
      state.threeDPointer = { x: event.clientX, y: event.clientY };
      dom.graph3dCanvas.classList.add("is-dragging");
      if (typeof dom.graph3dCanvas.setPointerCapture === "function") {
        try { dom.graph3dCanvas.setPointerCapture(event.pointerId); } catch (error) { /* ignored */ }
      }
    });
    dom.graph3dCanvas.addEventListener("pointermove", function (event) {
      if (!state.threeDDragging) {
        const hovered = hit3dNode(event.clientX, event.clientY);
        state.threeDHoverNodeId = hovered ? hovered.node.id : "";
        const hoveredEdge = hovered ? null : hit3dEdge(event.clientX, event.clientY);
        state.threeDHoverEdgeKey = hoveredEdge ? hoveredEdge.key : "";
        dom.graph3dCanvas.style.cursor = hovered || hoveredEdge ? "pointer" : "grab";
        draw3dScene(0);
        return;
      }
      const dx = event.clientX - state.threeDPointer.x;
      const dy = event.clientY - state.threeDPointer.y;
      state.threeDDragDistance += Math.abs(dx) + Math.abs(dy);
      state.camera.yaw += dx * 0.005;
      state.camera.pitch = Math.max(-1.35, Math.min(1.35, state.camera.pitch + dy * 0.005));
      state.threeDPointer = { x: event.clientX, y: event.clientY };
      draw3dScene(0);
    });
    function end3dPointer(event, cancelled) {
      if (!state.threeDDragging) return;
      state.threeDDragging = false;
      dom.graph3dCanvas.classList.remove("is-dragging");
      if (!cancelled && state.threeDDragDistance < 8) {
        const hit = hit3dNode(event.clientX, event.clientY);
        if (hit) {
          const index = state.threeDGraph.nodes.findIndex(function (node) { return node.id === hit.node.id; });
          state.threeDFocusedIndex = Math.max(0, index);
          selectFocused3dNode();
        } else {
          const edge = hit3dEdge(event.clientX, event.clientY);
          if (edge) { rememberSelection(); state.selectedEdgeKey = edge.key; state.selectedEvidenceId = ""; if (!state.selectedId || (state.selectedId !== edge.source && state.selectedId !== edge.target)) state.selectedId = edge.source; renderEdgeDetails(edge); syncGraphTextSelection(); draw3dScene(0); }
        }
      }
      schedule3dFrame();
    }
    dom.graph3dCanvas.addEventListener("pointerleave", function () { state.threeDHoverNodeId = ""; state.threeDHoverEdgeKey = ""; draw3dScene(0); });
    dom.graph3dCanvas.addEventListener("pointerup", function (event) { end3dPointer(event, false); });
    dom.graph3dCanvas.addEventListener("pointercancel", function (event) { end3dPointer(event, true); });
    dom.graph3dCanvas.addEventListener("wheel", function (event) {
      event.preventDefault();
      state.cameraTransition = null;
      state.camera.zoom = Math.max(0.35, Math.min(2.4, state.camera.zoom * (event.deltaY > 0 ? 0.9 : 1.1)));
      draw3dScene(0);
    }, { passive: false });
    dom.graph3dCanvas.addEventListener("keydown", function (event) {
      if (!state.threeDGraph || !state.threeDGraph.nodes.length) return;
      let handled = true;
      if (event.key === "ArrowLeft") state.camera.yaw -= 0.12;
      else if (event.key === "ArrowRight") state.camera.yaw += 0.12;
      else if (event.key === "ArrowUp") state.threeDFocusedIndex = (state.threeDFocusedIndex - 1 + state.threeDGraph.nodes.length) % state.threeDGraph.nodes.length;
      else if (event.key === "ArrowDown") state.threeDFocusedIndex = (state.threeDFocusedIndex + 1) % state.threeDGraph.nodes.length;
      else if (event.key === "Home" || event.key === "0" || event.key.toLowerCase() === "f") reset3dCamera();
      else if (event.key === "Enter" || event.key === " ") selectFocused3dNode();
      else if (event.key === "Escape") {
        if (state.selectionHistory.length) goBack(); else setHidden(dom.detailsPanel, true);
      } else if (event.key === "+" || event.key === "=") state.camera.zoom = Math.min(2.4, state.camera.zoom * 1.16);
      else if (event.key === "-" || event.key === "_") state.camera.zoom = Math.max(0.35, state.camera.zoom / 1.16);
      else handled = false;
      if (handled) {
        event.preventDefault();
        const focused = state.threeDGraph.nodes[state.threeDFocusedIndex];
        if (focused && ["ArrowUp", "ArrowDown"].includes(event.key)) announce(focused.name + "에 키보드 초점");
        draw3dScene(0);
      }
    });
    document.addEventListener("keydown", function (event) {
      if (event.key !== "/" || (!event.metaKey && !event.ctrlKey) || event.altKey) return;
      const target = event.target;
      const editing = target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
      if (!editing) {
        event.preventDefault();
        dom.searchInput.focus();
        dom.searchInput.select();
      }
    });
    window.addEventListener("resize", function () {
      if (state.viewMode === "3d") draw3dScene(0);
      if (state.cy && !dom.graphView.hidden) {
        state.cy.resize();
        state.cy.fit(state.cy.elements(), 46);
      }
    });
    document.addEventListener("visibilitychange", function () {
      if (document.visibilityState === "hidden") stop3dFrame();
      else if (
        state.viewMode === "3d" &&
        !dom.graphView.hidden &&
        state.activeLens !== "overview" &&
        state.activeLens !== "changes"
      ) schedule3dFrame();
    });
    if (typeof reducedMotionQuery.addEventListener === "function") {
      reducedMotionQuery.addEventListener("change", function (event) {
        if (event.matches) { state.motionEnabled = false; if (state.cameraTransition) Object.assign(state.camera, state.cameraTransition.to); state.cameraTransition = null; }
        updateMotionControl();
        draw3dScene(0);
        schedule3dFrame();
      });
    }
  }

  const initialSelection = readSelectionLink();
  state.restoringSelection = true;
  initializeMeta();
  renderQualityPanel();
  populateFacet(
    dom.languageFilter,
    new Set(nodes.map(function (node) { return node.language; })),
    "모든 언어"
  );
  populateFacet(
    dom.typeFilter,
    new Set(nodes.map(function (node) { return node.type; })),
    "모든 유형"
  );
  renderOverview();
  renderChanges();
  updateMotionControl();
  bindEvents();
  runSearch();
  setViewMode("3d", "", false, true);
  switchLens(initialSelection ? initialSelection.lens : "architecture", initialSelection ? initialSelection.entity : "", true);
  if (initialSelection && initialSelection.edge) {
    state.selectedEdgeKey = initialSelection.edge.key;
    state.selectedEvidenceId = initialSelection.evidenceId;
    renderEdgeDetails(initialSelection.edge);
  }
  state.restoringSelection = false;
})();

SHA-256: b8661f642bb2653430bd5665fffda66e81a1d068fca745ec79bd013eabf052e3