518 lines
18 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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}`)));
|