meeting_memory/meeting_memory/web_demo/static/graph.js

518 lines
18 KiB
JavaScript

const graphForm = document.getElementById("graphSearchForm");
const graphQueryInput = document.getElementById("graphQueryInput");
const graphNodeLimit = document.getElementById("graphNodeLimit");
const graphEdgeLimit = document.getElementById("graphEdgeLimit");
const graphSvg = document.getElementById("graphSvg");
const graphMeta = document.getElementById("graphMeta");
const graphDetail = document.getElementById("graphDetail");
const relatedSearch = document.getElementById("relatedSearch");
const graphTypeFilter = document.getElementById("graphTypeFilter");
let selectedEntityTypes = null;
let selectedKinds = null;
const TRUNCATE_LENGTH = 16;
function h(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function truncate(text, maxLen) {
if (!text || text.length <= maxLen) return text || "";
return text.slice(0, maxLen - 1) + "…";
}
function empty(message) {
return `<div class="empty-state">${h(message)}</div>`;
}
async function loadGraphKinds() {
try {
const kindRes = await fetch("/api/graph-kinds");
const kindData = await kindRes.json();
const kinds = kindData.kinds || [];
if (kinds.length) {
selectedKinds = new Set(kinds.map((k) => k.kind));
}
let html = "";
if (kinds.length) {
html += `<span class="field-label" style="margin-right:4px">节点类型:</span>`;
html += kinds.map((k) =>
`<label><input type="checkbox" class="kind-cb" value="${h(k.kind)}" checked> ${h(k.kind)} (${k.count})</label>`
).join("");
html += `<label><input type="checkbox" class="kind-cb" id="kindSelectAll" checked> 全选</label>`;
}
graphTypeFilter.innerHTML = html;
graphTypeFilter.querySelectorAll(".kind-cb").forEach((cb) => {
cb.addEventListener("change", () => {
if (cb.id === "kindSelectAll") {
const checked = cb.checked;
graphTypeFilter.querySelectorAll(".kind-cb:not(#kindSelectAll)").forEach((c) => c.checked = checked);
selectedKinds = checked ? new Set(kinds.map((k) => k.kind)) : new Set();
} else {
if (!cb.checked) {
document.getElementById("kindSelectAll").checked = false;
selectedKinds.delete(cb.value);
} else {
selectedKinds.add(cb.value);
if (selectedKinds.size === kinds.length) {
document.getElementById("kindSelectAll").checked = true;
}
}
}
fetchGraph().catch((error) => renderInspector(empty(`图谱加载失败: ${error}`)));
});
});
} catch (_) {}
}
function renderInspector(content) {
graphDetail.innerHTML = content;
}
async function loadRelated(query) {
if (!query) {
relatedSearch.innerHTML = "";
return;
}
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=4`);
const payload = await response.json();
const results = payload.results || [];
if (!results.length) {
relatedSearch.innerHTML = empty("没有更多相关检索结果");
return;
}
relatedSearch.innerHTML = `
<div class="panel-head">
<div>
<p class="eyebrow">Related</p>
<h3>相关检索</h3>
</div>
</div>
${results.map((item) => `
<article class="result-card">
<strong>${h(item.title || item.kind || "结果")}</strong>
<p>${h(item.text || "")}</p>
</article>
`).join("")}
`;
}
function renderGraph(payload) {
const nodes = payload.nodes || [];
const edges = payload.edges || [];
const stage = document.getElementById("graphStage");
const rect = stage.getBoundingClientRect();
const svgW = Math.max(600, rect.width - 4);
const svgH = Math.max(400, rect.height - 4);
graphSvg.setAttribute("viewBox", `0 0 ${svgW} ${svgH}`);
graphSvg.setAttribute("width", svgW);
graphSvg.setAttribute("height", svgH);
graphMeta.textContent = `节点 ${nodes.length} · 关系 ${edges.length} · Neo4j ${payload.stats?.enabled ? "已启用" : "未启用"}`;
if (!nodes.length) {
graphSvg.innerHTML = "";
renderInspector(empty("当前没有可显示的图谱数据"));
relatedSearch.innerHTML = "";
return;
}
const nodeRadius = (node) => Math.max(14, Math.min(28, 12 + (node.degree || 0) * 1.4));
const dataNodes = nodes.map((node, i) => ({
...node,
x: svgW / 2 + Math.cos((Math.PI * 2 * i) / nodes.length) * Math.min(svgW, svgH) * 0.28,
y: svgH / 2 + Math.sin((Math.PI * 2 * i) / nodes.length) * Math.min(svgW, svgH) * 0.26,
vx: 0, vy: 0,
pinned: false,
radius: nodeRadius(node),
}));
const nodeById = new Map(dataNodes.map((n) => [n.id, n]));
const dataEdges = edges
.map((e) => ({ ...e, sourceNode: nodeById.get(e.source), targetNode: nodeById.get(e.target) }))
.filter((e) => e.sourceNode && e.targetNode);
const n = dataNodes.length;
const area = svgW * svgH;
const repulsionStr = Math.max(3000, area / Math.max(n, 1));
const linkDist = Math.max(80, Math.min(200, 240 - n));
const linkStr = Math.min(0.06, 4 / Math.max(n, 1));
let zoomTransform = { x: 0, y: 0, k: 1 };
let isDragging = false;
let dragOccurred = false;
let dragNode = null;
let dragOffX = 0, dragOffY = 0;
let isPanning = false;
let panStartX = 0, panStartY = 0;
let simAlpha = 1;
let simRunning = false;
let simId = null;
const mainGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
graphSvg.innerHTML = "";
graphSvg.appendChild(mainGroup);
const edgeEls = dataEdges.map((edge) => {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("data-edge-id", edge.id);
g.setAttribute("class", "edge-wrap");
g.style.cursor = "pointer";
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("class", "graph-edge");
g.appendChild(line);
if (edge.predicate) {
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("text-anchor", "middle");
text.setAttribute("font-size", "11");
text.setAttribute("fill", "#7d86b4");
text.setAttribute("data-type", "edge-label");
text.textContent = truncate(edge.predicate, 20);
g.appendChild(text);
}
mainGroup.appendChild(g);
return g;
});
const nodeEls = dataNodes.map((node) => {
const r = node.radius;
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
const kindClass = `graph-node--${(node.kind || 'entity').toLowerCase()}`;
g.setAttribute("class", `graph-node ${kindClass}`);
g.setAttribute("data-node-id", node.id);
g.style.cursor = "grab";
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", r);
g.appendChild(circle);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("y", r + 16);
text.setAttribute("text-anchor", "middle");
text.setAttribute("font-size", "11");
text.setAttribute("fill", "#22264d");
text.setAttribute("data-type", "node-label");
text.textContent = truncate(node.label, TRUNCATE_LENGTH);
g.appendChild(text);
mainGroup.appendChild(g);
return g;
});
function syncDom() {
for (let i = 0; i < dataEdges.length; i++) {
const edge = dataEdges[i], el = edgeEls[i];
if (!el) continue;
const line = el.querySelector("line");
if (line) {
line.setAttribute("x1", edge.sourceNode.x.toFixed(1));
line.setAttribute("y1", edge.sourceNode.y.toFixed(1));
line.setAttribute("x2", edge.targetNode.x.toFixed(1));
line.setAttribute("y2", edge.targetNode.y.toFixed(1));
}
const label = el.querySelector("text[data-type='edge-label']");
if (label) {
const mx = (edge.sourceNode.x + edge.targetNode.x) / 2;
const my = (edge.sourceNode.y + edge.targetNode.y) / 2;
const angle = Math.atan2(edge.targetNode.y - edge.sourceNode.y, edge.targetNode.x - edge.sourceNode.x) * (180 / Math.PI);
label.setAttribute("x", mx.toFixed(1));
label.setAttribute("y", (my + (Math.abs(angle) < 30 || Math.abs(angle) > 150 ? -12 : 4)).toFixed(1));
}
}
for (let i = 0; i < dataNodes.length; i++) {
const node = dataNodes[i], el = nodeEls[i];
if (el) el.setAttribute("transform", `translate(${node.x.toFixed(1)} ${node.y.toFixed(1)})`);
}
}
function tick() {
const alpha = simAlpha;
if (alpha < 0.001) {
simRunning = false;
simId = null;
syncDom();
return;
}
for (let i = 0; i < dataNodes.length; i++) {
for (let j = i + 1; j < dataNodes.length; j++) {
const a = dataNodes[i], b = dataNodes[j];
let dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const minDist = (a.radius + b.radius) * 1.6;
if (dist < minDist) {
const push = (minDist - dist) / dist * 0.5;
a.vx -= dx * push; a.vy -= dy * push;
b.vx += dx * push; b.vy += dy * push;
}
const force = (repulsionStr * alpha) / (dist * dist + 1);
const fx = force * dx / dist, fy = force * dy / dist;
a.vx -= fx; a.vy -= fy;
b.vx += fx; b.vy += fy;
}
}
for (const edge of dataEdges) {
const s = edge.sourceNode, t = edge.targetNode;
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - linkDist) * linkStr * alpha;
const fx = force * dx / dist, fy = force * dy / dist;
s.vx += fx; s.vy += fy;
t.vx -= fx; t.vy -= fy;
}
const cx = svgW / 2, cy = svgH / 2;
const grav = 0.005 * alpha;
for (const node of dataNodes) {
if (node.pinned) continue;
node.vx += (cx - node.x) * grav;
node.vy += (cy - node.y) * grav;
}
simAlpha *= 0.992;
if (simAlpha < 0.001) simAlpha = 0;
for (const node of dataNodes) {
if (node.pinned) continue;
node.vx *= 0.6;
node.vy *= 0.6;
node.x += node.vx;
node.y += node.vy;
node.x = Math.max(20, Math.min(svgW - 20, node.x));
node.y = Math.max(20, Math.min(svgH - 20, node.y));
}
syncDom();
simId = requestAnimationFrame(tick);
}
function startSim() {
if (simRunning) return;
simRunning = true;
simAlpha = Math.max(simAlpha, 0.15);
if (simId) cancelAnimationFrame(simId);
simId = requestAnimationFrame(tick);
}
function wakeSim() {
simAlpha = Math.max(simAlpha, 0.3);
if (!simRunning) startSim();
}
function applyTransform() {
mainGroup.setAttribute("transform", `translate(${zoomTransform.x} ${zoomTransform.y}) scale(${zoomTransform.k})`);
}
graphSvg.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newK = Math.max(0.15, Math.min(6, zoomTransform.k * delta));
const r = graphSvg.getBoundingClientRect();
const cx = e.clientX - r.left, cy = e.clientY - r.top;
zoomTransform.x = cx - (cx - zoomTransform.x) * (newK / zoomTransform.k);
zoomTransform.y = cy - (cy - zoomTransform.y) * (newK / zoomTransform.k);
zoomTransform.k = newK;
applyTransform();
});
graphSvg.addEventListener("mousedown", (e) => {
const target = e.target.closest("[data-node-id]");
if (target) {
isDragging = true;
dragOccurred = false;
dragNode = dataNodes.find((n) => n.id === target.dataset.nodeId);
if (dragNode) {
const r = graphSvg.getBoundingClientRect();
dragOffX = (e.clientX - r.left - zoomTransform.x) / zoomTransform.k - dragNode.x;
dragOffY = (e.clientY - r.top - zoomTransform.y) / zoomTransform.k - dragNode.y;
target.style.cursor = "grabbing";
wakeSim();
}
return;
}
if (e.target === graphSvg || e.target === mainGroup) {
isPanning = true;
panStartX = e.clientX - zoomTransform.x;
panStartY = e.clientY - zoomTransform.y;
graphSvg.style.cursor = "grabbing";
}
});
window.addEventListener("mousemove", (e) => {
if (isDragging && dragNode) {
dragOccurred = true;
const r = graphSvg.getBoundingClientRect();
dragNode.x = (e.clientX - r.left - zoomTransform.x) / zoomTransform.k - dragOffX;
dragNode.y = (e.clientY - r.top - zoomTransform.y) / zoomTransform.k - dragOffY;
dragNode.pinned = true;
syncDom();
} else if (isPanning) {
zoomTransform.x = e.clientX - panStartX;
zoomTransform.y = e.clientY - panStartY;
applyTransform();
}
});
window.addEventListener("mouseup", () => {
if (isDragging && dragNode) {
const el = graphSvg.querySelector(`[data-node-id="${dragNode.id}"]`);
if (el) el.style.cursor = "grab";
dragNode.vx = 0;
dragNode.vy = 0;
wakeSim();
}
isDragging = false;
dragNode = null;
isPanning = false;
graphSvg.style.cursor = "";
});
nodeEls.forEach((el) => {
el.addEventListener("click", (e) => {
if (dragOccurred) { dragOccurred = false; return; }
const node = nodes.find((item) => item.id === el.dataset.nodeId);
const related = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
const kind = (node.kind || "Entity").toLowerCase();
let body = "";
if (kind === "meeting" || kind === "episode") {
body = `
<p>${h(node.description || node.summary || "暂无描述")}</p>
<div class="chip-row">
${node.date ? `<span class="chip">${h(node.date)}</span>` : ""}
<span class="chip">关系 ${h(related.length)}</span>
</div>`;
} else if (kind === "fact") {
body = `
<p>${h(node.fact || node.description || "暂无描述")}</p>
<div class="chip-row">
${node.date ? `<span class="chip">${h(node.date)}</span>` : ""}
<span class="chip">关系 ${h(related.length)}</span>
</div>`;
} else {
body = `
<p>${h(node.description || "暂无描述")}</p>
<div class="chip-row">
${node.entity_type ? `<span class="chip">${h(node.entity_type)}</span>` : ""}
${node.date ? `<span class="chip">${h(node.date)}</span>` : ""}
<span class="chip">关系 ${h(related.length)}</span>
</div>`;
}
renderInspector(`
<div class="detail-card">
<p class="eyebrow">${h(node.kind)}</p>
<h3>${h(node.label)}</h3>
${body}
</div>
${related.map((edge) => `
<article class="result-card">
<strong>${h(edge.source)}${h(edge.target)}</strong>
<p>${h(edge.fact || edge.description || edge.predicate || "")}</p>
</article>
`).join("")}
`);
loadRelated(node.label).catch(() => relatedSearch.innerHTML = empty("相关检索加载失败"));
});
});
edgeEls.forEach((el) => {
el.addEventListener("click", () => {
graphSvg.querySelectorAll(".graph-edge.active").forEach((item) => item.classList.remove("active"));
const line = el.querySelector(".graph-edge");
line?.classList.add("active");
const edge = edges.find((item) => item.id === el.dataset.edgeId);
renderInspector(`
<div class="detail-card">
<p class="eyebrow">Edge</p>
<h3>${h(edge.source)}${h(edge.target)}</h3>
<p>${h(edge.fact || edge.description || "暂无补充描述")}</p>
<div class="chip-row">
${edge.predicate ? `<span class="chip">${h(edge.predicate)}</span>` : ""}
${edge.date ? `<span class="chip">${h(edge.date)}</span>` : ""}
<span class="chip">置信度 ${h(edge.confidence ?? 0)}</span>
${edge.meeting_id ? `<span class="chip">${h(edge.meeting_id)}</span>` : ""}
</div>
</div>
`);
loadRelated(`${edge.source} ${edge.predicate} ${edge.target}`).catch(() => relatedSearch.innerHTML = empty("相关检索加载失败"));
});
});
const resetBtn = document.createElement("button");
resetBtn.className = "btn ghost zoom-reset-btn";
resetBtn.textContent = "重置视图";
resetBtn.addEventListener("click", () => {
zoomTransform = { x: 0, y: 0, k: 1 };
applyTransform();
});
const pauseBtn = document.createElement("button");
pauseBtn.className = "btn ghost pause-btn";
pauseBtn.textContent = "⏸ 暂停";
pauseBtn.addEventListener("click", () => {
if (simRunning) {
simRunning = false;
if (simId) cancelAnimationFrame(simId);
simId = null;
pauseBtn.textContent = "▶ 继续";
} else {
wakeSim();
pauseBtn.textContent = "⏸ 暂停";
}
});
document.querySelectorAll(".zoom-reset-btn, .pause-btn").forEach((el) => el.remove());
const toolbar = document.querySelector(".graph-toolbar .graph-controls");
if (toolbar) {
const wrap = toolbar.parentElement;
const hint = document.createElement("div");
hint.className = "zoom-hint";
hint.innerHTML = `滚轮缩放 · 空白拖拽平移 · 拖拽节点重排 · 物理动画自动冷却`;
wrap.appendChild(hint);
const btnRow = document.createElement("div");
btnRow.className = "graph-toolbar-row";
btnRow.appendChild(resetBtn);
btnRow.appendChild(pauseBtn);
wrap.appendChild(btnRow);
}
startSim();
syncDom();
}
async function fetchGraph() {
const query = graphQueryInput.value.trim();
const limitNodes = graphNodeLimit.value || "60";
const limitEdges = graphEdgeLimit.value || "120";
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("limit_nodes", limitNodes);
params.set("limit_edges", limitEdges);
if (selectedKinds && selectedKinds.size > 0) {
selectedKinds.forEach((k) => params.append("kinds", k));
}
renderInspector(empty("图谱加载中..."));
const response = await fetch(`/api/graph?${params.toString()}`);
const payload = await response.json();
renderGraph(payload);
}
graphForm?.addEventListener("submit", (event) => {
event.preventDefault();
fetchGraph().catch((error) => renderInspector(empty(`图谱加载失败: ${error}`)));
});
loadGraphKinds().catch(() => {});
fetchGraph().catch((error) => renderInspector(empty(`图谱加载失败: ${error}`)));