const state = {
eventTypes: [],
fields: [],
lastQuery: null,
lastResult: null,
};
const $ = (id) => document.getElementById(id);
async function apiPost(path, body = {}) {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok || data["错误"]) {
throw new Error(data["错误信息"] || "请求失败");
}
return data;
}
async function loadInitialData() {
const health = await fetch("/api/健康检查").then((r) => r.json());
$("statusText").textContent = `数据已加载:${health["事件类型数"]} 类事件,${health["节点数"]} 个节点,${health["关系数"]} 条关系`;
const data = await apiPost("/api/事件类型列表");
state.eventTypes = data["事件类型列表"];
const select = $("eventTypeSelect");
select.innerHTML = state.eventTypes.map((name) => ``).join("");
select.addEventListener("change", onEventTypeChange);
await onEventTypeChange();
}
async function onEventTypeChange() {
const eventType = $("eventTypeSelect").value;
const data = await apiPost("/api/可筛选字段", { "事件类型": eventType });
state.fields = data["可筛选字段"];
renderFieldList();
$("filterRows").innerHTML = "";
addFilterRow();
await queryGraph();
}
function renderFieldList() {
$("fieldList").innerHTML = state.fields.map((field) => `${escapeHtml(field)}`).join("");
}
function addFilterRow() {
const container = $("filterRows");
const row = document.createElement("div");
row.className = "filter-row";
row.innerHTML = `
`;
row.querySelector(".filter-field").addEventListener("change", async (event) => {
await loadCandidates(event.target.value);
});
row.querySelector(".remove-filter").addEventListener("click", () => {
row.remove();
});
container.appendChild(row);
if (state.fields[0]) {
loadCandidates(state.fields[0]);
}
}
async function loadCandidates(field) {
const eventType = $("eventTypeSelect").value;
try {
const data = await apiPost("/api/字段候选值", { "事件类型": eventType, "字段": field });
ensureDatalist(data["候选值"].slice(0, 80));
} catch {
ensureDatalist([]);
}
}
function ensureDatalist(values) {
let datalist = $("candidateList");
if (!datalist) {
datalist = document.createElement("datalist");
datalist.id = "candidateList";
document.body.appendChild(datalist);
}
datalist.innerHTML = values
.map((item) => ``)
.join("");
}
function collectFilters() {
const filters = {};
document.querySelectorAll(".filter-row").forEach((row) => {
const field = row.querySelector(".filter-field").value;
const value = row.querySelector(".filter-value").value.trim();
if (!field || !value) {
return;
}
if (filters[field]) {
filters[field] = Array.isArray(filters[field]) ? filters[field].concat(value) : [filters[field], value];
} else {
filters[field] = value;
}
});
return filters;
}
async function queryGraph() {
const query = {
"事件类型": $("eventTypeSelect").value,
"筛选条件": collectFilters(),
};
state.lastQuery = query;
setBusy(true);
try {
const result = await apiPost("/api/知识图谱查询", query);
state.lastResult = result;
renderResult(result);
} catch (error) {
alert(error.message);
} finally {
setBusy(false);
}
}
function renderResult(result) {
$("metricEvents").textContent = result["命中事件数"];
$("metricNodes").textContent = result["节点数"];
$("metricLinks").textContent = result["关系数"];
$("metricType").textContent = result["事件类型"];
$("jsonPreview").textContent = JSON.stringify(result, null, 2);
renderEvents(result["命中事件列表"] || []);
renderGraph(result);
}
function renderEvents(events) {
const list = $("eventList");
if (!events.length) {
list.innerHTML = '没有命中事件。
';
return;
}
list.innerHTML = events
.slice(0, 200)
.map(
(event) => `
${escapeHtml(event["本地任务ID"] || event["事件ID"])}
${escapeHtml(event["原始文件"] || "")}
${escapeHtml(event["原文摘要"] || "")}
`
)
.join("");
}
function renderGraph(result) {
const canvas = $("graphCanvas");
const detail = ensureGraphDetail();
detail.hidden = true;
if (!window.KgNvlPreview) {
canvas.innerHTML = '图谱预览库未加载。
';
return;
}
const preview = window.KgNvlPreview.render(canvas, result, {
maxNodes: canvas.clientWidth < 620 ? 220 : 320,
maxLinks: canvas.clientWidth < 620 ? 380 : 620,
onNodeSelect: (node, degree) => showGraphDetail(detail, { ...node, degree }),
});
const info = ensureGraphInfo();
info.textContent = `NVL 图谱预览:${preview.nodes.length} 个节点、${preview.links.length} 条关系。滚轮缩放,拖动画布,点击节点查看详情。`;
}
function buildPreviewGraph(result, maxNodes, maxLinks) {
const allNodes = result.staticNodes || [];
const allLinks = result.staticLinks || [];
const nodeById = new Map(allNodes.map((node) => [node.id, node]));
const degree = new Map();
allLinks.forEach((link) => {
degree.set(link.source, (degree.get(link.source) || 0) + 1);
degree.set(link.target, (degree.get(link.target) || 0) + 1);
});
const ranked = allNodes
.map((node, index) => ({
...node,
degree: degree.get(node.id) || 0,
originalIndex: index,
nodeClass: (node.labels || [])[0] || node.ontName || "Entity",
}))
.sort((a, b) => nodeRank(b) - nodeRank(a) || a.originalIndex - b.originalIndex)
.slice(0, maxNodes);
const selected = new Set(ranked.map((node) => node.id));
const links = allLinks
.filter((link) => selected.has(link.source) && selected.has(link.target))
.sort((a, b) => linkRank(b, degree) - linkRank(a, degree))
.slice(0, maxLinks);
return {
nodes: ranked.map((node) => ({
...node,
r: radiusForNode(node),
})),
links,
nodeById,
};
}
function nodeRank(node) {
const labels = node.labels || [];
const base = labels.includes("EventType") ? 10000 : labels.includes("Event") ? 5000 : 0;
return base + (node.degree || 0) * 20;
}
function linkRank(link, degree) {
const relationWeight = link.type === "BELONGS_TO" ? 1000 : link.type === "HAS_ENTITY" ? 500 : 0;
return relationWeight + (degree.get(link.source) || 0) + (degree.get(link.target) || 0);
}
function radiusForNode(node) {
const labels = node.labels || [];
if (labels.includes("EventType")) return 24;
if (labels.includes("Event")) return 16;
return Math.min(15, 8 + Math.sqrt(node.degree || 0) * 1.8);
}
function runForceLayout(nodes, links, width, height) {
const centerX = width / 2;
const centerY = height / 2;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const spread = Math.min(width, height) * 0.34;
nodes.forEach((node, index) => {
const angle = (index / nodes.length) * Math.PI * 2;
const isEvent = (node.labels || []).includes("Event") || (node.labels || []).includes("EventType");
const baseRadius = isEvent ? spread * 0.25 : spread * (0.55 + ((index % 7) / 16));
node.x = centerX + Math.cos(angle) * baseRadius;
node.y = centerY + Math.sin(angle) * baseRadius;
node.vx = 0;
node.vy = 0;
});
for (let tick = 0; tick < 260; tick += 1) {
const alpha = 1 - tick / 260;
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j += 1) {
const b = nodes[j];
const dx = a.x - b.x || 0.01;
const dy = a.y - b.y || 0.01;
const distanceSq = dx * dx + dy * dy;
const force = Math.min(6.5, 780 / distanceSq) * alpha;
const distance = Math.sqrt(distanceSq);
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
links.forEach((link) => {
const source = nodeById.get(link.source);
const target = nodeById.get(link.target);
if (!source || !target) return;
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const ideal = link.type === "BELONGS_TO" ? 90 : link.type === "HAS_ENTITY" ? 120 : 160;
const force = (distance - ideal) * 0.018 * alpha;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.vx += fx;
source.vy += fy;
target.vx -= fx;
target.vy -= fy;
});
nodes.forEach((node) => {
const isEventType = (node.labels || []).includes("EventType");
const targetX = centerX;
const targetY = centerY;
const gravity = isEventType ? 0.06 : 0.012;
node.vx += (targetX - node.x) * gravity * alpha;
node.vy += (targetY - node.y) * gravity * alpha;
node.vx *= 0.78;
node.vy *= 0.78;
node.x = clamp(node.x + node.vx, 48, width - 48);
node.y = clamp(node.y + node.vy, 66, height - 48);
});
}
}
function importantLabels(nodes, width) {
const limit = width < 600 ? 8 : nodes.length > 180 ? 26 : 48;
return nodes
.filter((node) => (node.labels || []).includes("EventType") || (node.labels || []).includes("Event") || node.degree >= 4)
.sort((a, b) => nodeRank(b) - nodeRank(a))
.slice(0, limit);
}
function ensureGraphDetail() {
let detail = $("graphDetail");
if (!detail) {
detail = document.createElement("aside");
detail.id = "graphDetail";
detail.className = "graph-detail";
$("graphView").appendChild(detail);
}
return detail;
}
function ensureGraphInfo() {
let info = $("graphInfo");
if (!info) {
info = document.createElement("div");
info.id = "graphInfo";
info.className = "graph-info";
$("graphView").appendChild(info);
}
return info;
}
function showGraphDetail(detail, node) {
detail.hidden = false;
detail.innerHTML = `
${escapeHtml(node.name || "节点")}
- 类型
- ${escapeHtml(node.ontName || "-")}
- 连接数
- ${escapeHtml(String(node.degree || 0))}
- ID
- ${escapeHtml(node.id || "-")}
`;
}
function enableGraphPanZoom(svg, root) {
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart = null;
const apply = () => root.setAttribute("transform", `translate(${offsetX},${offsetY}) scale(${scale})`);
svg.addEventListener("wheel", (event) => {
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
scale = clamp(scale * delta, 0.45, 2.8);
apply();
});
svg.addEventListener("pointerdown", (event) => {
if (event.target.closest(".graph-node")) return;
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
apply();
});
svg.addEventListener("pointerup", () => {
dragStart = null;
});
}
function startNodeDrag(event, node, group, linkLayer, nodeById) {
event.preventDefault();
event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, nodeX: node.x, nodeY: node.y };
group.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
node.x = start.nodeX + moveEvent.clientX - start.x;
node.y = start.nodeY + moveEvent.clientY - start.y;
group.setAttribute("transform", `translate(${node.x},${node.y})`);
Array.from(linkLayer.children).forEach((line) => {
const source = nodeById.get(line.__dataSource);
const target = nodeById.get(line.__dataTarget);
if (!source || !target) return;
line.setAttribute("x1", source.x);
line.setAttribute("y1", source.y);
line.setAttribute("x2", target.x);
line.setAttribute("y2", target.y);
});
};
const up = () => {
group.removeEventListener("pointermove", move);
group.removeEventListener("pointerup", up);
};
group.addEventListener("pointermove", move);
group.addEventListener("pointerup", up);
}
function svgEl(tag, attrs = {}, text = "") {
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value));
if (text) el.textContent = text;
return el;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function colorForOntName(name) {
if (name === "Event") return "#2563eb";
if (name === "EventType") return "#0e7490";
if (name && name.includes("位置")) return "#16a34a";
if (name && name.includes("时间")) return "#d97706";
if (name && name.includes("人员")) return "#7c3aed";
if (name && name.includes("船")) return "#0284c7";
return "#475569";
}
async function downloadJson() {
if (!state.lastQuery) {
await queryGraph();
}
const response = await fetch("/api/知识图谱下载", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.lastQuery),
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${state.lastQuery["事件类型"]}_查询结果.json`;
a.click();
URL.revokeObjectURL(url);
}
function setBusy(isBusy) {
$("queryButton").disabled = isBusy;
$("downloadButton").disabled = isBusy;
$("queryButton").textContent = isBusy ? "查询中" : "查询图谱";
}
function truncate(value, length) {
return value.length > length ? `${value.slice(0, length)}...` : value;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function escapeSvg(value) {
return escapeHtml(value).replaceAll("'", "'");
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((item) => item.classList.remove("active"));
document.querySelectorAll(".tab-view").forEach((item) => item.classList.remove("active"));
tab.classList.add("active");
$(`${tab.dataset.tab}View`).classList.add("active");
});
});
$("addFilterButton").addEventListener("click", addFilterRow);
$("queryButton").addEventListener("click", queryGraph);
$("downloadButton").addEventListener("click", downloadJson);
$("resetButton").addEventListener("click", async () => {
$("filterRows").innerHTML = "";
addFilterRow();
await queryGraph();
});
loadInitialData().catch((error) => {
$("statusText").textContent = error.message;
});