Files
llm-wiki/templates/graph-styles/wash/graph-wash.js
T
2026-07-12 21:26:08 +08:00

1010 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* DEPRECATED: Legacy wash runtime retained for one release cycle after Stage 4 engine HTML migration. Do not extend; use packages/graph-engine instead. */
/* ============================================================
Knowledge Graph - Oriental editorial atlas runtime
============================================================ */
(function () {
"use strict";
const helpers = window.WikiGraphWashHelpers;
if (!helpers) {
console.error("[wiki] graph-wash-helpers.js is missing or failed to load");
return;
}
const {
createSafeStorage,
getWikiStorageNamespace,
defaultQueue,
normalizeQueue,
toggleQueueFavorite,
appendQueueNote,
summarizeQueue,
buildAtlasModel,
deriveAtlasLayout,
resolveAtlasVisibleSnapshot,
resolveAtlasSelectedNodeId,
atlasNodePoint,
getAtlasModelBounds,
fitAtlasViewport,
centerAtlasViewportOnPoint,
zoomAtlasViewport,
atlasViewportToMinimapRect,
atlasPointToMinimap,
minimapPointToAtlasPoint,
atlasConfidenceLabel,
atlasTypeLabel,
atlasNodeKind,
stripAtlasMarkdown
} = helpers;
const DENSITY_SMALL_LIMIT = 80;
const DENSITY_MEDIUM_LIMIT = 200;
const DENSITY_LARGE_LIMIT = 500;
const QUEUE_NOTE_LIMIT = 50;
const NOTE_EXCERPT_LIMIT = 140;
const COMMUNITY_COLORS = ["#8b2e24", "#315f72", "#4b7564", "#b7791f", "#6f557f", "#3e6b4b", "#9b6a36", "#5d6f91"];
const dataEl = document.getElementById("graph-data");
let DATA;
let dataError = false;
try {
DATA = dataEl ? JSON.parse(dataEl.textContent) : window.SAMPLE_GRAPH;
} catch (err) {
console.error("[wiki] graph data parse failed:", err);
DATA = { meta: {}, nodes: [], edges: [], insights: { meta: { degraded: true } } };
dataError = true;
}
const app = document.getElementById("app");
const atlas = document.getElementById("atlas");
const nodeLayer = document.getElementById("node-layer");
const edgeLayer = document.getElementById("edge-layer");
const communityList = document.getElementById("community-list");
const startList = document.getElementById("start-list");
const searchInput = document.getElementById("search");
const noResults = document.getElementById("no-results");
const canvasTitle = document.getElementById("canvas-title");
const canvasSubtitle = document.getElementById("canvas-subtitle");
const insightTitle = document.getElementById("insight-title");
const insightCopy = document.getElementById("insight-copy");
const drawer = document.getElementById("drawer");
const drawerNeighbors = document.getElementById("neighbor-details");
const drawerNeighborsHeading = drawerNeighbors ? drawerNeighbors.querySelector("summary") : null;
const neighborList = document.getElementById("neighbor-list");
const minimapEl = document.getElementById("minimap");
const minimapToggle = document.getElementById("minimap-toggle");
const minimapSvg = document.getElementById("mini-map-svg");
if (!atlas || !nodeLayer || !edgeLayer) {
console.error("[wiki] atlas shell is incomplete");
return;
}
let rawLocalStorage = null;
try {
rawLocalStorage = window.localStorage;
} catch (_) {}
const atlasModel = buildAtlasModel(DATA);
const atlasLayout = deriveAtlasLayout(atlasModel);
const safeLocalStorage = createSafeStorage(rawLocalStorage, console.warn);
const storageNamespace = getWikiStorageNamespace(atlasModel.meta, window.location && window.location.pathname);
const state = {
atlasModel,
atlasLayout,
queue: loadQueueState(),
ui: {
selectedNodeId: null,
activeCommunityId: "all",
focusMode: "all",
query: "",
dimUnselected: false,
dataMode: dataError ? "error" : (atlasModel.nodes.length ? "normal" : "empty"),
neighborExpanded: false,
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true }
},
viewport: { x: 0, y: 0, scale: 1 },
viewportReady: false,
visible: null
};
let viewportPaintFrame = 0;
let panState = null;
function queueStorageKey(name) {
return storageNamespace + ":" + name;
}
function loadQueueState() {
const raw = safeLocalStorage.get(queueStorageKey("queue"));
if (!raw) return defaultQueue();
try {
return normalizeQueue(JSON.parse(raw));
} catch (err) {
console.warn("[wiki] queue storage parse failed:", err);
return defaultQueue();
}
}
function persistQueueState() {
safeLocalStorage.set(queueStorageKey("queue"), JSON.stringify(state.queue));
}
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[ch]);
}
function clampWeight(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0.6;
return Math.max(0, Math.min(1, numeric));
}
function edgeStrokeWidth(edge) {
return 1.1 + clampWeight(edge && edge.weight) * 1.8;
}
function edgeOpacity(edge) {
return 0.32 + clampWeight(edge && edge.weight) * 0.44;
}
function edgeStrengthSize(edge) {
return 6 + clampWeight(edge && edge.weight) * 8;
}
function currentDensityMode() {
return state.visible ? state.visible.densityMode : "card";
}
function communityColor(communityId) {
const community = state.atlasModel.communityById[communityId];
const index = community ? community.color_index || 0 : 0;
return COMMUNITY_COLORS[index % COMMUNITY_COLORS.length];
}
function getSelectedNode() {
const selectedNodeId = resolveAtlasSelectedNodeId(state.atlasModel, state.visible, state.ui.selectedNodeId);
return selectedNodeId ? state.atlasModel.byId[selectedNodeId] || null : null;
}
function getPreviewStartEntry(visibleSnapshot) {
if (state.ui.selectedNodeId) return null;
const visible = visibleSnapshot || state.visible || refreshVisibleSnapshot();
const visibleIds = new Set((visible.node_ids || []).map(String));
const starts = visible.starts && visible.starts.length
? visible.starts
: state.atlasModel.starts.filter((entry) => entry && entry.node && visibleIds.has(entry.node.id));
if (starts.length) return starts[0];
const fallback = (visible.nodes || []).slice().filter((node) => {
return node && (node.summary || node.content || node.source_path);
}).sort((left, right) => (right.priority || 0) - (left.priority || 0))[0];
return fallback ? { node: fallback, reason: "当前范围 · 推荐预览" } : null;
}
function refreshVisibleSnapshot() {
state.visible = resolveAtlasVisibleSnapshot(state.atlasModel, state.atlasLayout, state.ui);
state.ui.selectedNodeId = resolveAtlasSelectedNodeId(state.atlasModel, state.visible, state.ui.selectedNodeId);
return state.visible;
}
function currentViewportSize() {
const rect = atlas.getBoundingClientRect();
return {
width: rect && rect.width ? rect.width : 1000,
height: rect && rect.height ? rect.height : 680
};
}
function viewportOptions() {
return { minScale: 0.62, maxScale: 3.2 };
}
function edgeTransformForViewport(viewport, size) {
const safeSize = size || currentViewportSize();
const x = (viewport.x / safeSize.width) * 1000;
const y = (viewport.y / safeSize.height) * 680;
return `translate(${x} ${y}) scale(${viewport.scale})`;
}
function applyViewportTransform() {
const size = currentViewportSize();
if (nodeLayer) {
nodeLayer.style.transform = `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.scale})`;
}
if (edgeLayer) {
edgeLayer.setAttribute("transform", edgeTransformForViewport(state.viewport, size));
}
updateMinimapViewport();
}
function updateMinimapViewport() {
if (!minimapSvg) return;
const rect = minimapSvg.querySelector(".mini-map-viewport");
if (!rect) return;
const miniRect = atlasViewportToMinimapRect(state.viewport, currentViewportSize());
rect.setAttribute("x", String(miniRect.x));
rect.setAttribute("y", String(miniRect.y));
rect.setAttribute("width", String(miniRect.width));
rect.setAttribute("height", String(miniRect.height));
}
function scheduleViewportPaint() {
if (viewportPaintFrame) return;
viewportPaintFrame = window.requestAnimationFrame(() => {
viewportPaintFrame = 0;
applyViewportTransform();
});
}
function setViewport(nextViewport, immediate) {
state.viewport = helpers.clampAtlasViewport(nextViewport, currentViewportSize(), viewportOptions());
if (immediate) {
if (viewportPaintFrame) {
window.cancelAnimationFrame(viewportPaintFrame);
viewportPaintFrame = 0;
}
applyViewportTransform();
} else {
scheduleViewportPaint();
}
}
function fitVisibleViewport() {
const visible = state.visible || refreshVisibleSnapshot();
const bounds = getAtlasModelBounds(visible.nodes, visible.nodes.length <= 1 ? 160 : 56);
setViewport(fitAtlasViewport(bounds, currentViewportSize(), { padding: 0.82, minScale: 0.62, maxScale: 1.18 }), true);
state.viewportReady = true;
}
function centerViewportOnNode(nodeId) {
const node = state.atlasModel.byId[nodeId];
if (!node) return;
const scale = Math.max(1.05, Math.min(state.viewport.scale || 1, 1.6));
setViewport(centerAtlasViewportOnPoint(atlasNodePoint(node), currentViewportSize(), scale, viewportOptions()), true);
state.viewportReady = true;
}
function makePath(a, b, edge) {
const sourcePoint = atlasNodePoint(a);
const targetPoint = atlasNodePoint(b);
const x1 = sourcePoint.x;
const y1 = sourcePoint.y;
const x2 = targetPoint.x;
const y2 = targetPoint.y;
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const curve = Math.max(-76, Math.min(76, (a.y - b.y) * 1.8 + (clampWeight(edge.weight) - 0.5) * 24));
return `M ${x1} ${y1} Q ${mx + curve} ${my - 22} ${x2} ${y2}`;
}
function edgeClass(edge) {
return String(edge.type || "EXTRACTED").toLowerCase();
}
function connectedIds(id) {
const out = new Set([id]);
state.atlasModel.edges.forEach((edge) => {
if (edge.source === id) out.add(edge.target);
if (edge.target === id) out.add(edge.source);
});
return out;
}
function isNodeImportant(node) {
return !!(node && state.visible && state.visible.importantNodeIds && state.visible.importantNodeIds[node.id]);
}
function nodeVisualRole(node, displayMode, previewNodeId) {
if (!node) return "landmark";
if (node.id === state.ui.selectedNodeId) return "cinnabar-note";
if (displayMode === "point" || displayMode === "overview") return "map-pin";
if (state.visible && state.visible.matchedNodeIds[node.id]) return "index-slip";
if (previewNodeId && node.id === previewNodeId) return "index-slip";
if (isNodeImportant(node)) return "index-slip";
return "landmark";
}
function nodeDisplayMode(node, previewNodeId) {
const mode = currentDensityMode();
if (!node) return "card";
if (node.id === state.ui.selectedNodeId) return "card";
if (state.visible && state.visible.matchedNodeIds[node.id]) return "card";
if (previewNodeId && node.id === previewNodeId && (mode === "overview" || mode === "point-plus-focus")) return "compact-card";
if (isNodeImportant(node) && (mode === "overview" || mode === "point-plus-focus")) return "compact-card";
if (mode === "overview") return state.visible.labelNodeIds[node.id] ? "compact-card" : "overview";
if (mode === "point-plus-focus") return state.visible.labelNodeIds[node.id] ? "compact-card" : "point";
return mode;
}
function renderTopbar() {
const title = document.getElementById("wiki-title");
if (title) title.textContent = `${state.atlasModel.meta.wiki_title} 知识舆图`;
}
function renderSidebar() {
if (!communityList || !startList) return;
const visible = state.visible || refreshVisibleSnapshot();
communityList.innerHTML = "";
const allButton = document.createElement("button");
allButton.className = "nav-item";
allButton.type = "button";
allButton.dataset.community = "all";
allButton.setAttribute("aria-pressed", state.ui.activeCommunityId === "all" ? "true" : "false");
allButton.innerHTML = `
<i class="swatch" style="background:${COMMUNITY_COLORS[0]}"></i>
<span class="nav-copy"><strong>全部社区</strong><span>${state.atlasModel.nodes.length} 个节点</span></span>
<span class="count">ALL</span>
`;
allButton.addEventListener("click", () => setCommunity("all"));
communityList.appendChild(allButton);
state.atlasModel.communities.forEach((community) => {
const button = document.createElement("button");
button.className = "nav-item";
button.type = "button";
button.dataset.community = community.id;
button.setAttribute("aria-pressed", state.ui.activeCommunityId === community.id ? "true" : "false");
button.innerHTML = `
<i class="swatch" style="background:${communityColor(community.id)}"></i>
<span class="nav-copy"><strong>${escapeHtml(community.label)}</strong><span>${community.node_count || 0} 个节点</span></span>
<span class="count">${community.node_count || 0}</span>
`;
button.addEventListener("click", () => setCommunity(community.id));
communityList.appendChild(button);
});
startList.innerHTML = "";
const previewEntry = getPreviewStartEntry(visible);
const previewNodeId = previewEntry && previewEntry.node ? previewEntry.node.id : null;
const starts = visible.starts.length ? visible.starts : state.atlasModel.starts;
starts.slice(0, 4).forEach((entry) => {
const node = entry.node;
const button = document.createElement("button");
button.className = "start-card";
button.type = "button";
button.dataset.previewStart = node.id === previewNodeId ? "true" : "false";
button.innerHTML = `<span class="card-copy"><strong>${escapeHtml(node.label)}</strong><span>${escapeHtml(entry.reason || atlasTypeLabel(node.type))}</span></span>`;
button.addEventListener("click", () => focusNode(node.id, true));
startList.appendChild(button);
});
if (!startList.children.length) {
const empty = document.createElement("div");
empty.className = "note-card";
empty.textContent = "暂无推荐起点。";
startList.appendChild(empty);
}
const summary = summarizeQueue(state.queue, state.atlasModel.byId, 3);
const metrics = document.querySelectorAll(".queue-metrics .metric b");
if (metrics[0]) metrics[0].textContent = summary.favorite_count;
if (metrics[1]) metrics[1].textContent = summary.note_count;
const queueList = document.querySelector(".queue-list");
if (queueList) {
queueList.innerHTML = "";
if (!summary.recent_items.length) {
const empty = document.createElement("div");
empty.className = "note-card";
empty.textContent = "选中节点后可加入学习队列。";
queueList.appendChild(empty);
return;
}
summary.recent_items.slice(0, 3).forEach((item) => {
const node = state.atlasModel.byId[item.node_id];
const button = document.createElement("button");
const kindLabel = item.kind === "note" ? "札记" : "待读";
const meta = node ? `${atlasTypeLabel(node.type)} · ${atlasConfidenceLabel(node.confidence)}` : "队列条目";
button.className = "queue-item";
button.type = "button";
button.dataset.kind = item.kind;
button.setAttribute("aria-current", item.node_id === state.ui.selectedNodeId ? "true" : "false");
button.innerHTML = `
<i class="queue-item__marker" aria-hidden="true"></i>
<span class="queue-item__copy"><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(meta)}</span></span>
<span class="queue-item__badge">${kindLabel}</span>
`;
button.addEventListener("click", () => focusNode(item.node_id, true));
queueList.appendChild(button);
});
}
}
function renderCanvas() {
const visible = state.visible || refreshVisibleSnapshot();
atlas.dataset.mode = state.ui.dataMode;
atlas.dataset.density = visible.densityMode;
edgeLayer.innerHTML = "";
visible.edges.forEach((edge) => {
const source = state.atlasModel.byId[edge.source];
const target = state.atlasModel.byId[edge.target];
if (!source || !target) return;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", makePath(source, target, edge));
path.setAttribute("class", `edge ${edgeClass(edge)}`);
path.setAttribute("data-from", edge.source);
path.setAttribute("data-to", edge.target);
path.setAttribute("data-edge-id", edge.id);
path.style.strokeWidth = edgeStrokeWidth(edge);
path.style.opacity = edgeOpacity(edge);
edgeLayer.appendChild(path);
});
nodeLayer.innerHTML = "";
const previewEntry = getPreviewStartEntry(visible);
const previewNodeId = previewEntry && previewEntry.node ? previewEntry.node.id : null;
visible.nodes.forEach((node) => {
const displayMode = nodeDisplayMode(node, previewNodeId);
const visualRole = nodeVisualRole(node, displayMode, previewNodeId);
const button = document.createElement("button");
button.className = "node";
if (node.unavailable) button.classList.add("is-disabled");
if (displayMode === "compact-card") button.classList.add("is-compact");
if (displayMode === "point") button.classList.add("is-point");
if (displayMode === "overview") button.classList.add("is-overview");
if (visualRole === "index-slip") button.classList.add("is-index-slip");
if (visualRole === "cinnabar-note") button.classList.add("is-cinnabar-note");
if (visualRole === "map-pin") button.classList.add("is-map-pin");
if (node.id === previewNodeId) button.classList.add("is-preview-start");
if (state.visible && !state.visible.labelNodeIds[node.id]) button.classList.add("is-label-hidden");
button.type = "button";
button.dataset.id = node.id;
button.dataset.type = node.type;
button.dataset.community = node.community;
button.dataset.densityMode = displayMode;
button.dataset.visualRole = visualRole;
button.dataset.startNode = state.visible && state.visible.startNodeIds[node.id] ? "true" : "false";
button.dataset.previewStart = node.id === previewNodeId ? "true" : "false";
button.style.left = `${node.x}%`;
button.style.top = `${node.y}%`;
button.title = node.label;
button.setAttribute("aria-pressed", node.id === state.ui.selectedNodeId ? "true" : "false");
button.innerHTML = `
<span class="node-kind">${node.kind}</span>
<span class="node-name">${escapeHtml(node.label)}</span>
<span class="node-meta"><i class="spark"></i>${node.unavailable ? "来源暂不可用" : Math.round(node.priority || node.weight || 0)}</span>
`;
button.addEventListener("click", () => selectNode(node.id));
button.addEventListener("mouseenter", () => highlightNeighborhood(node.id));
button.addEventListener("mouseleave", () => applyFilters());
nodeLayer.appendChild(button);
});
applyFilters();
renderMinimap();
applyViewportTransform();
}
function setDrawerActions(mode, node) {
const queueAction = document.getElementById("queue-action");
const sourceAction = document.getElementById("source-action");
if (queueAction) {
queueAction.textContent = mode === "preview" ? "从这里开始" : "加入学习队列";
queueAction.disabled = mode === "empty";
}
if (sourceAction) {
sourceAction.textContent = "查看来源";
sourceAction.disabled = !(node && node.source_path);
}
}
function renderStartPreview(entry) {
const node = entry && entry.node;
if (!node) return false;
const neighbors = getNeighbors(node.id);
const community = state.atlasModel.communityById[node.community];
const communityLabel = community ? community.label : "未分组";
const excerpt = stripAtlasMarkdown(node.content || node.summary || "").slice(0, 220);
document.getElementById("drawer-kind").innerHTML = `<span class="spark"></span>从这里开始 · 预览`;
document.getElementById("drawer-title").textContent = node.label;
document.getElementById("drawer-subtitle").textContent = `${entry.reason || atlasTypeLabel(node.type)} · ${communityLabel} · 点击后进入阅读态`;
document.getElementById("drawer-summary").textContent = node.summary || excerpt || "这个节点适合作为当前图谱的起点。";
document.getElementById("drawer-neighbor-count").textContent = `${neighbors.length} 个`;
const content = document.getElementById("drawer-content");
if (content) {
content.innerHTML = `
<p>这是一条推荐起点预览。全局图仍保持中立,点击“从这里开始”或左侧推荐起点后,才会进入选中阅读态。</p>
<p>${escapeHtml(excerpt || node.summary || "当前节点暂无正文,但可以从相邻节点继续展开。")}</p>
`;
}
if (neighborList) {
neighborList.innerHTML = "";
if (!neighbors.length) {
const empty = document.createElement("div");
empty.className = "note-card";
empty.textContent = "这个起点暂时没有相邻节点。";
neighborList.appendChild(empty);
}
neighbors.slice(0, 4).forEach((entryItem) => {
const neighbor = entryItem.node;
const button = document.createElement("button");
button.className = "neighbor-card";
button.type = "button";
button.innerHTML = `<span class="card-copy"><strong>${escapeHtml(neighbor.label)}</strong><span>${atlasTypeLabel(neighbor.type)} · ${atlasConfidenceLabel(entryItem.edge.type)}</span></span>`;
button.addEventListener("click", () => focusNode(neighbor.id, true));
neighborList.appendChild(button);
});
}
setDrawerActions("preview", node);
return true;
}
function renderDrawer() {
const selected = getSelectedNode();
if (!drawer) return;
const previewEntry = getPreviewStartEntry();
if (app) {
app.dataset.reading = selected ? "1" : "0";
app.dataset.startPreview = !selected && previewEntry ? "1" : "0";
}
drawer.dataset.state = selected ? "reading" : (previewEntry ? "start-preview" : "empty");
if (!selected && previewEntry && renderStartPreview(previewEntry)) return;
if (!selected) {
setDrawerActions("empty", null);
document.getElementById("drawer-kind").innerHTML = `<span class="spark"></span>当前范围`;
document.getElementById("drawer-title").textContent = "没有匹配节点";
document.getElementById("drawer-subtitle").textContent = "调整搜索或筛选后查看知识内容";
document.getElementById("drawer-summary").textContent = "当前范围内没有可显示的节点。";
document.getElementById("drawer-neighbor-count").textContent = "0 个";
const content = document.getElementById("drawer-content");
if (content) content.innerHTML = "<p>清除搜索词或切回全部社区后,可以继续查看节点摘要和知识内容。</p>";
if (neighborList) {
neighborList.innerHTML = "";
const empty = document.createElement("div");
empty.className = "note-card";
empty.textContent = "暂无相邻节点。";
neighborList.appendChild(empty);
}
return;
}
state.ui.selectedNodeId = selected.id;
setDrawerActions("reading", selected);
const neighbors = getNeighbors(selected.id);
const community = state.atlasModel.communityById[selected.community];
const communityLabel = community ? community.label : "未分组";
document.getElementById("drawer-kind").innerHTML = `<span class="spark"></span>${atlasTypeLabel(selected.type)} · 已选中`;
document.getElementById("drawer-title").textContent = selected.label;
document.getElementById("drawer-subtitle").textContent = `${communityLabel} · ${atlasConfidenceLabel(selected.confidence)}${selected.source_path ? " · " + selected.source_path : ""}`;
document.getElementById("drawer-summary").textContent = selected.summary || "暂无摘要。";
document.getElementById("drawer-neighbor-count").textContent = `${neighbors.length} 个`;
renderKnowledgeCard(selected, neighbors);
if (neighborList) {
neighborList.innerHTML = "";
if (!neighbors.length) {
const empty = document.createElement("div");
empty.className = "note-card";
empty.textContent = "这个节点暂时没有相邻节点。";
neighborList.appendChild(empty);
}
neighbors.forEach((entry) => {
const node = entry.node;
const button = document.createElement("button");
button.className = "neighbor-card";
button.type = "button";
button.innerHTML = `<span class="card-copy"><strong>${escapeHtml(node.label)}</strong><span>${atlasTypeLabel(node.type)} · ${atlasConfidenceLabel(entry.edge.type)}</span></span>`;
button.addEventListener("click", () => focusNode(node.id, true));
neighborList.appendChild(button);
});
}
}
function renderKnowledgeCard(node, neighbors) {
const content = document.getElementById("drawer-content");
if (!content) return;
const rawContent = String(node.content || "").trim();
if (rawContent) {
const linked = rawContent.replace(/\[\[([^\]]+)\]\]/g, (_, inner) => {
const parts = inner.split("|");
const target = parts[0].trim();
const label = (parts[1] || parts[0]).trim();
const exists = state.atlasModel.nodes.some((item) => item.id === target || item.label === target);
const cls = exists ? "wikilink" : "wikilink wikilink--dead";
return `<a class="${cls}" data-target="${escapeHtml(target)}">${escapeHtml(label)}</a>`;
});
const html = typeof marked === "undefined" ? `<p>${escapeHtml(stripAtlasMarkdown(linked))}</p>` : marked.parse(linked, { breaks: false, gfm: true });
const safe = typeof DOMPurify === "undefined"
? html
: DOMPurify.sanitize(html, { ADD_ATTR: ["target", "data-target", "tabindex"] });
content.innerHTML = safe;
} else {
const related = neighbors.length
? `它当前连接到 ${neighbors.map((entry) => `「${entry.node.label}」`).join("、")}。`
: "这个节点暂时没有相邻节点。";
content.innerHTML = `<p>${escapeHtml(node.label)}属于知识库中的「${escapeHtml(atlasTypeLabel(node.type))}」节点。</p><p>${escapeHtml(node.summary || related)}</p>`;
}
content.querySelectorAll("a.wikilink").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
if (link.classList.contains("wikilink--dead")) return;
const target = link.getAttribute("data-target");
const hit = state.atlasModel.nodes.find((item) => item.id === target || item.label === target);
if (hit) focusNode(hit.id, true);
});
});
}
function getNeighbors(nodeId) {
const out = [];
state.atlasModel.edges.forEach((edge) => {
if (edge.source === nodeId && state.atlasModel.byId[edge.target]) {
out.push({ node: state.atlasModel.byId[edge.target], edge, direction: "to" });
} else if (edge.target === nodeId && state.atlasModel.byId[edge.source]) {
out.push({ node: state.atlasModel.byId[edge.source], edge, direction: "from" });
}
});
return out.sort((left, right) => clampWeight(right.edge.weight) - clampWeight(left.edge.weight));
}
function renderInsights() {
if (!insightTitle || !insightCopy) return;
const visible = state.visible || refreshVisibleSnapshot();
const insights = state.atlasModel.insights;
const bridge = insights.bridge_nodes && insights.bridge_nodes[0];
const surprising = insights.surprising_connections && insights.surprising_connections[0];
if (surprising) {
insightTitle.textContent = "发现跨社区强连接";
insightCopy.textContent = `${surprising.from}${surprising.to} 的关系权重较高,适合作为下一步阅读线索。`;
} else if (bridge) {
insightTitle.textContent = "桥接节点值得优先阅读";
insightCopy.textContent = `${bridge.label || bridge.id} 连接多个社区,可帮助从局部主题进入全局图谱。`;
} else if (visible.densityMode === "overview") {
insightTitle.textContent = "当前视图过密";
insightCopy.textContent = "建议搜索关键词或筛选社区,先缩小图谱范围再阅读节点内容。";
} else {
insightTitle.textContent = "从选中节点进入阅读";
insightCopy.textContent = "右侧札记会随节点、搜索和社区筛选同步更新。";
}
}
function renderMinimap() {
if (!minimapSvg) return;
const visible = state.visible || refreshVisibleSnapshot();
while (minimapSvg.firstChild) minimapSvg.removeChild(minimapSvg.firstChild);
const ns = "http://www.w3.org/2000/svg";
const path = document.createElementNS(ns, "path");
path.setAttribute("d", "M8 40 C34 20 54 36 76 22 C98 8 118 24 150 12");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#cfc4b1");
path.setAttribute("stroke-width", "1.4");
minimapSvg.appendChild(path);
visible.nodes.slice(0, 60).forEach((node) => {
const point = atlasPointToMinimap(atlasNodePoint(node));
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", String(point.x));
circle.setAttribute("cy", String(point.y));
circle.setAttribute("r", node.id === state.ui.selectedNodeId ? "3.2" : "2.2");
circle.setAttribute("fill", communityColor(node.community));
if (node.id === state.ui.selectedNodeId) circle.classList.add("is-selected");
minimapSvg.appendChild(circle);
});
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("class", "mini-map-viewport");
rect.setAttribute("rx", "5");
minimapSvg.appendChild(rect);
updateMinimapViewport();
}
function applyFilters() {
const visible = state.visible || refreshVisibleSnapshot();
const visibleIds = new Set(visible.node_ids);
const selectedIds = state.ui.selectedNodeId ? connectedIds(state.ui.selectedNodeId) : new Set();
document.querySelectorAll(".node").forEach((nodeEl) => {
const id = nodeEl.dataset.id;
const isVisible = visibleIds.has(id);
nodeEl.classList.toggle("is-hidden", !isVisible);
const dim = state.ui.dimUnselected && state.ui.selectedNodeId && !selectedIds.has(id);
nodeEl.classList.toggle("is-dim", isVisible && dim);
nodeEl.setAttribute("aria-pressed", id === state.ui.selectedNodeId ? "true" : "false");
});
document.querySelectorAll(".edge").forEach((edgeEl) => {
const from = edgeEl.dataset.from;
const to = edgeEl.dataset.to;
const visibleEdge = visibleIds.has(from) && visibleIds.has(to);
const dim = state.ui.dimUnselected && state.ui.selectedNodeId && from !== state.ui.selectedNodeId && to !== state.ui.selectedNodeId;
edgeEl.classList.toggle("is-dim", !visibleEdge || dim);
});
const hasNoResults = !!state.ui.query && visible.nodes.length === 0 && state.ui.dataMode === "normal";
if (noResults) noResults.classList.toggle("is-visible", hasNoResults);
const communityName = state.ui.activeCommunityId === "all"
? "全局视图"
: (state.atlasModel.communityById[state.ui.activeCommunityId] || {}).label || "当前社区";
if (canvasTitle) canvasTitle.textContent = `知识地图 · ${communityName}`;
if (canvasSubtitle) {
const densityLabel = ({
card: "卡片",
"compact-card": "紧凑卡片",
"point-plus-focus": "点位聚焦",
overview: "总览"
})[visible.densityMode] || "卡片";
canvasSubtitle.textContent = hasNoResults
? "当前筛选没有匹配节点"
: `${visible.nodes.length} 个节点在当前范围内可见 · ${densityLabel}模式`;
}
}
function highlightNeighborhood(id) {
if (state.ui.dataMode !== "normal") return;
const ids = connectedIds(id);
document.querySelectorAll(".node").forEach((nodeEl) => {
nodeEl.classList.toggle("is-dim", !ids.has(nodeEl.dataset.id));
});
document.querySelectorAll(".edge").forEach((edgeEl) => {
edgeEl.classList.toggle("is-dim", edgeEl.dataset.from !== id && edgeEl.dataset.to !== id);
});
}
function renderAtlasView(options) {
const opts = options && typeof options === "object" ? options : {};
refreshVisibleSnapshot();
if (app) app.dataset.reading = state.ui.selectedNodeId ? "1" : "0";
if (opts.fitViewport || !state.viewportReady) fitVisibleViewport();
renderTopbar();
renderSidebar();
renderCanvas();
renderDrawer();
renderInsights();
}
function selectNode(id) {
if (!state.atlasModel.byId[id]) return;
state.ui.selectedNodeId = id;
renderAtlasView();
}
function focusNode(nodeId, openDrawer) {
if (!state.atlasModel.byId[nodeId]) return;
state.ui.selectedNodeId = nodeId;
if (openDrawer !== false && drawer) {
drawer.scrollIntoView({ block: "nearest", inline: "nearest" });
}
renderAtlasView();
centerViewportOnNode(nodeId);
}
function closeDrawer() {
state.ui.selectedNodeId = null;
renderAtlasView({ fitViewport: true });
}
function setCommunity(communityId) {
state.ui.activeCommunityId = communityId || "all";
renderAtlasView({ fitViewport: true });
}
function buildNoteText(node) {
const stripped = stripAtlasMarkdown(node && node.content);
const excerpt = stripped.slice(0, NOTE_EXCERPT_LIMIT);
return node && node.label ? `${node.label}${excerpt ? "" + excerpt : ""}` : excerpt;
}
function handleQueueAction() {
const node = getSelectedNode();
if (!node) {
const previewEntry = getPreviewStartEntry();
if (previewEntry && previewEntry.node) focusNode(previewEntry.node.id, true);
return;
}
state.queue = toggleQueueFavorite(state.queue, node.id);
state.queue = appendQueueNote(state.queue, {
id: `${node.id}:${Date.now()}`,
node_id: node.id,
label: node.label,
text: buildNoteText(node),
created_at: new Date().toISOString()
}, QUEUE_NOTE_LIMIT);
persistQueueState();
renderSidebar();
}
function setupSearch() {
if (!searchInput) return;
searchInput.addEventListener("input", () => {
state.ui.query = searchInput.value.trim().toLowerCase();
renderAtlasView({ fitViewport: true });
});
searchInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const hit = state.visible && state.visible.nodes[0];
if (hit) focusNode(hit.id, true);
});
}
function isCanvasPanTarget(target) {
return !(target && target.closest && target.closest(".node, button, a, input, textarea, summary, details"));
}
function setupViewportInteractions() {
atlas.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !isCanvasPanTarget(event.target)) return;
panState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
viewport: { x: state.viewport.x, y: state.viewport.y, scale: state.viewport.scale }
};
atlas.classList.add("is-panning");
atlas.setPointerCapture(event.pointerId);
event.preventDefault();
});
atlas.addEventListener("pointermove", (event) => {
if (!panState || panState.pointerId !== event.pointerId) return;
setViewport({
x: panState.viewport.x + event.clientX - panState.startX,
y: panState.viewport.y + event.clientY - panState.startY,
scale: panState.viewport.scale
});
event.preventDefault();
});
function finishPan(event) {
if (!panState || panState.pointerId !== event.pointerId) return;
panState = null;
atlas.classList.remove("is-panning");
if (atlas.hasPointerCapture && atlas.hasPointerCapture(event.pointerId)) {
atlas.releasePointerCapture(event.pointerId);
}
}
atlas.addEventListener("pointerup", finishPan);
atlas.addEventListener("pointercancel", finishPan);
atlas.addEventListener("wheel", (event) => {
if (!isCanvasPanTarget(event.target) && !(event.target && event.target.closest && event.target.closest(".node"))) return;
const rect = atlas.getBoundingClientRect();
const factor = Math.exp(-event.deltaY * 0.0012);
setViewport(zoomAtlasViewport(state.viewport, factor, {
x: event.clientX - rect.left,
y: event.clientY - rect.top
}, currentViewportSize(), viewportOptions()));
event.preventDefault();
}, { passive: false });
}
function setupMinimapNavigation() {
if (!minimapSvg) return;
minimapSvg.addEventListener("click", (event) => {
const matrix = minimapSvg.getScreenCTM && minimapSvg.getScreenCTM();
if (!matrix) return;
const point = minimapSvg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const local = point.matrixTransform(matrix.inverse());
const atlasPoint = minimapPointToAtlasPoint({ x: local.x, y: local.y });
setViewport(centerAtlasViewportOnPoint(atlasPoint, currentViewportSize(), state.viewport.scale, viewportOptions()), true);
});
}
function setupControls() {
document.querySelectorAll("[data-focus]").forEach((button) => {
button.addEventListener("click", () => {
state.ui.focusMode = button.dataset.focus || "all";
document.querySelectorAll("[data-focus]").forEach((item) => {
item.setAttribute("aria-pressed", item === button ? "true" : "false");
});
renderAtlasView({ fitViewport: true });
});
});
document.querySelectorAll(".state-button[data-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.ui.dataMode = button.dataset.mode || "normal";
atlas.dataset.mode = state.ui.dataMode;
document.querySelectorAll(".state-button[data-mode]").forEach((item) => {
item.setAttribute("aria-pressed", item === button ? "true" : "false");
});
applyFilters();
});
});
const dimButton = document.getElementById("toggle-dim");
if (dimButton) {
dimButton.addEventListener("click", () => {
state.ui.dimUnselected = !state.ui.dimUnselected;
dimButton.textContent = state.ui.dimUnselected ? "显示全部层级" : "弱化未选中";
applyFilters();
});
}
const fitButton = document.getElementById("fit-view");
if (fitButton) {
fitButton.addEventListener("click", () => {
state.ui.activeCommunityId = "all";
state.ui.focusMode = "all";
state.ui.query = "";
state.ui.selectedNodeId = null;
state.ui.dataMode = "normal";
if (searchInput) searchInput.value = "";
document.querySelectorAll("[data-focus]").forEach((item) => {
item.setAttribute("aria-pressed", item.dataset.focus === "all" ? "true" : "false");
});
document.querySelectorAll(".state-button[data-mode]").forEach((item) => {
item.setAttribute("aria-pressed", item.dataset.mode === "normal" ? "true" : "false");
});
renderAtlasView({ fitViewport: true });
});
}
const queueAction = document.getElementById("queue-action");
if (queueAction) queueAction.addEventListener("click", handleQueueAction);
const sourceAction = document.getElementById("source-action");
if (sourceAction) {
sourceAction.addEventListener("click", () => {
const previewEntry = getPreviewStartEntry();
const node = getSelectedNode() || (previewEntry && previewEntry.node);
if (node && node.source_path) window.location.href = node.source_path;
});
}
}
function applyNeighborsCollapsed(collapsed) {
if (!drawerNeighbors || !drawerNeighborsHeading) return;
drawerNeighbors.open = !collapsed;
drawerNeighbors.setAttribute("data-collapsed", collapsed ? "1" : "0");
drawerNeighborsHeading.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
function toggleNeighbors() {
if (!drawerNeighbors) return;
const nextCollapsed = drawerNeighbors.open;
applyNeighborsCollapsed(nextCollapsed);
safeLocalStorage.set(queueStorageKey("neighbors-collapsed"), nextCollapsed ? "1" : "0");
}
function setupNeighborToggle() {
const storedNeighborsCollapsed = safeLocalStorage.get(queueStorageKey("neighbors-collapsed"));
applyNeighborsCollapsed(storedNeighborsCollapsed == null ? true : storedNeighborsCollapsed === "1");
if (!drawerNeighbors || !drawerNeighborsHeading) return;
drawerNeighbors.addEventListener("toggle", () => {
state.ui.neighborExpanded = drawerNeighbors.open;
drawerNeighbors.setAttribute("data-collapsed", drawerNeighbors.open ? "0" : "1");
drawerNeighborsHeading.setAttribute("aria-expanded", drawerNeighbors.open ? "true" : "false");
});
drawerNeighborsHeading.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleNeighbors();
}
});
}
function applyMinimapCollapsed(collapsed) {
if (!minimapEl || !minimapToggle) return;
minimapEl.setAttribute("data-collapsed", collapsed ? "1" : "0");
minimapToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
minimapToggle.setAttribute("aria-label", collapsed ? "展开小地图" : "折叠小地图");
}
setupSearch();
setupViewportInteractions();
setupMinimapNavigation();
setupControls();
setupNeighborToggle();
applyMinimapCollapsed(false);
window.addEventListener("resize", () => renderAtlasView({ fitViewport: true }));
renderAtlasView({ fitViewport: true });
})();