3588AdminBackend/internal/web/ui/assets/graph_editor.js

807 lines
33 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.

(function () {
const root = document.querySelector(".graph-editor");
const jsonEl = document.getElementById("graph-template-json");
if (!root || !jsonEl) return;
const svg = root.querySelector(".graph-canvas");
const canvasWrap = root.querySelector(".graph-canvas-wrap");
const nodeForm = root.querySelector(".graph-node-form");
const edgeForm = root.querySelector(".graph-edge-form");
const empty = root.querySelector(".graph-empty-inspector");
const saveForm = root.closest(".graph-editor-card").querySelector(".graph-save-form");
const deleteNodeBtn = root.querySelector(".graph-delete-node");
const deleteEdgeBtn = root.querySelector(".graph-delete-edge");
const connectBtn = root.querySelector(".graph-connect-node");
const connectTarget = root.querySelector(".graph-connect-target");
const typedParamFields = root.querySelector(".graph-typed-param-fields");
const autoLayoutBtn = root.querySelector(".graph-auto-layout");
const paletteList = root.querySelector(".graph-node-palette-list");
const rawJSON = (jsonEl.textContent && jsonEl.textContent.trim())
|| (jsonEl.innerHTML && jsonEl.innerHTML.trim())
|| (jsonEl.content && jsonEl.content.textContent && jsonEl.content.textContent.trim())
|| "{}";
const doc = JSON.parse(rawJSON);
const graph = doc.template || {};
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
const edges = Array.isArray(graph.edges) ? graph.edges : [];
graph.nodes = nodes;
graph.edges = edges;
doc.template = graph;
const fallbackNodeTypes = [
{ type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } },
{ type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } },
{ type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } },
{ type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } },
{ type: "ai_scrfd_sliding", label: "滑窗人脸检测", category: "AI 推理", icon: "scan-face", description: "使用滑窗方式执行 SCRFD 人脸检测,适合高分辨率画面。", defaults: { id: "ai_scrfd_sliding", type: "ai_scrfd_sliding", role: "filter", enable: true } },
{ type: "ai_face_det", label: "人脸检测", category: "AI 推理", icon: "face", description: "通用人脸检测节点,输出人脸框和质量信息。", defaults: { id: "ai_face_det", type: "ai_face_det", role: "filter", enable: true } },
{ type: "ai_face_recog", label: "人脸识别", category: "AI 推理", icon: "face-id", description: "对检测到的人脸进行特征提取和人脸库匹配。", defaults: { id: "face_recog", type: "ai_face_recog", role: "filter", enable: true, infer_fps: 2 } },
{ type: "ai_yolo", label: "YOLO 目标检测", category: "AI 推理", icon: "target", description: "使用 YOLO 模型检测人员、PPE 或其他目标。", defaults: { id: "ai_yolo", type: "ai_yolo", role: "filter", enable: true, infer_fps: 2, conf: 0.35, nms: 0.45 } },
{ type: "ai_shoe_det", label: "鞋靴检测", category: "AI 推理", icon: "shoe", description: "检测鞋靴和工鞋相关目标,可配合逻辑节点判断违规。", defaults: { id: "ai_shoe_det", type: "ai_shoe_det", role: "filter", enable: true } },
{ type: "tracker", label: "目标跟踪", category: "处理", icon: "route", description: "对检测目标分配跟踪 ID保持跨帧目标状态。", defaults: { id: "tracker", type: "tracker", role: "filter", enable: true } },
{ type: "logic_gate", label: "规则判断", category: "规则", icon: "branch", description: "根据检测、跟踪或颜色分析结果进行业务规则判断。", defaults: { id: "logic_gate", type: "logic_gate", role: "filter", enable: true, expression: "" } },
{ type: "event_fusion", label: "事件融合", category: "规则", icon: "merge", description: "融合多路事件,减少重复告警并形成更稳定的业务事件。", defaults: { id: "event_fusion", type: "event_fusion", role: "filter", enable: true } },
{ type: "region_event", label: "区域事件", category: "规则", icon: "region", description: "基于区域、越线或停留规则生成区域行为事件。", defaults: { id: "region_event", type: "region_event", role: "filter", enable: true } },
{ type: "action_recog", label: "行为识别", category: "AI 推理", icon: "activity", description: "识别人员行为或动作事件。", defaults: { id: "action_recog", type: "action_recog", role: "filter", enable: true } },
{ type: "det_post", label: "检测后处理", category: "处理", icon: "filter", description: "对检测结果做过滤、映射、合并或类别转换。", defaults: { id: "det_post", type: "det_post", role: "filter", enable: true } },
{ type: "osd", label: "画面叠加", category: "输出", icon: "overlay", description: "在视频帧上绘制检测框、文字、人脸识别和事件信息。", defaults: { id: "osd", type: "osd", role: "filter", enable: true } },
{ type: "publish", label: "视频输出", category: "输出", icon: "broadcast", description: "编码并发布 RTSP、HLS 或其他视频输出。", defaults: { id: "publish", type: "publish", role: "sink", enable: true } },
{ type: "storage", label: "本地存储", category: "输出", icon: "database", description: "保存帧、事件或中间结果到本地存储。", defaults: { id: "storage", type: "storage", role: "sink", enable: true } },
{ type: "alarm", label: "告警动作", category: "输出", icon: "bell", description: "根据规则触发日志、抓图、录像片段、外部接口等动作。", defaults: { id: "alarm", type: "alarm", role: "sink", enable: true, level: "warning" } },
{ type: "gate", label: "流控闸门", category: "系统", icon: "gate", description: "控制流程分支或限流,保护下游节点。", defaults: { id: "gate", type: "gate", role: "filter", enable: true } },
{ type: "zlm_http", label: "ZLMediaKit HTTP", category: "系统", icon: "server", description: "提供 ZLMediaKit 相关 HTTP 文件服务能力。", defaults: { id: "zlm_http", type: "zlm_http", role: "sink", enable: true } }
];
const coreNodeKeys = new Set(["id", "type", "role", "enable"]);
const coreEdgeKeys = new Set(["from", "to"]);
const paramSchemas = {
input_rtsp: [
{ key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" },
{ key: "fps", label: "输入帧率", type: "number", step: "1" },
{ key: "width", label: "宽度", type: "number", step: "1" },
{ key: "height", label: "高度", type: "number", step: "1" },
{ key: "force_tcp", label: "强制 TCP", type: "boolean" },
{ key: "reconnect_sec", label: "重连间隔秒", type: "number", step: "1" }
],
preprocess: [
{ key: "dst_w", label: "输出宽度", type: "number", step: "1" },
{ key: "dst_h", label: "输出高度", type: "number", step: "1" },
{ key: "dst_format", label: "输出格式", type: "select", options: ["rgb", "nv12", "bgr"] },
{ key: "resize_mode", label: "缩放方式", type: "select", options: ["stretch", "letterbox"] },
{ key: "use_rga", label: "使用 RGA", type: "boolean" },
{ key: "rga_gate", label: "RGA 通道", type: "text" }
],
ai_yolo: [
{ key: "model_path", label: "模型路径", type: "text" },
{ key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
{ key: "model_w", label: "模型宽度", type: "number", step: "1" },
{ key: "model_h", label: "模型高度", type: "number", step: "1" },
{ key: "conf", label: "置信度", type: "number", step: "0.01" },
{ key: "nms", label: "NMS", type: "number", step: "0.01" }
],
ai_shoe_det: [
{ key: "model_path", label: "模型路径", type: "text" },
{ key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
{ key: "conf", label: "置信度", type: "number", step: "0.01" },
{ key: "nms", label: "NMS", type: "number", step: "0.01" },
{ key: "append_detections", label: "追加检测结果", type: "boolean" }
],
ai_scrfd_sliding: [
{ key: "model_path", label: "模型路径", type: "text" },
{ key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
{ key: "conf_thresh", label: "置信度", type: "number", step: "0.01" },
{ key: "nms_thresh", label: "NMS", type: "number", step: "0.01" },
{ key: "max_faces", label: "最大人脸数", type: "number", step: "1" }
],
ai_face_recog: [
{ key: "model_path", label: "模型路径", type: "text" },
{ key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
{ key: "align", label: "人脸对齐", type: "boolean" },
{ key: "emit_embedding", label: "输出特征", type: "boolean" },
{ key: "max_faces", label: "最大人脸数", type: "number", step: "1" }
],
tracker: [
{ key: "mode", label: "跟踪模式", type: "text" },
{ key: "per_class", label: "按类别跟踪", type: "boolean" },
{ key: "high_th", label: "高阈值", type: "number", step: "0.01" },
{ key: "low_th", label: "低阈值", type: "number", step: "0.01" },
{ key: "iou_th", label: "IOU 阈值", type: "number", step: "0.01" },
{ key: "max_age_ms", label: "最大保留毫秒", type: "number", step: "1" }
],
logic_gate: [
{ key: "mode", label: "逻辑模式", type: "text" },
{ key: "debug", label: "调试输出", type: "boolean" },
{ key: "anchor_class", label: "锚点类别", type: "number", step: "1" },
{ key: "boots_class", label: "鞋靴类别", type: "number", step: "1" },
{ key: "violation_class", label: "违规类别", type: "number", step: "1" }
],
osd: [
{ key: "draw_bbox", label: "绘制框", type: "boolean" },
{ key: "draw_text", label: "绘制文字", type: "boolean" },
{ key: "draw_face_det", label: "绘制人脸检测", type: "boolean" },
{ key: "draw_face_recog", label: "绘制人脸识别", type: "boolean" },
{ key: "line_width", label: "线宽", type: "number", step: "0.1" },
{ key: "font_scale", label: "字体缩放", type: "number", step: "0.1" }
],
publish: [
{ key: "codec", label: "编码", type: "select", options: ["h264", "h265"] },
{ key: "fps", label: "输出帧率", type: "number", step: "1" },
{ key: "gop", label: "GOP", type: "number", step: "1" },
{ key: "bitrate_kbps", label: "码率 kbps", type: "number", step: "1" },
{ key: "use_mpp", label: "使用 MPP", type: "boolean" },
{ key: "use_ffmpeg_mux", label: "FFmpeg 封装", type: "boolean" }
],
alarm: [
{ key: "eval_fps", label: "评估帧率", type: "number", step: "0.1" }
]
};
function catalogFromItems(items) {
const out = {};
(items || []).forEach((item) => {
if (!item || !item.type || !item.defaults) return;
out[item.type] = item;
if (Array.isArray(item.params)) paramSchemas[item.type] = item.params;
});
return out;
}
let nodeCatalog = catalogFromItems(fallbackNodeTypes);
const layout = (((doc.ui || {}).layout || {}).nodes) || {};
const hasSavedLayout = Object.keys(layout).length > 0;
const nodeSize = { w: 142, h: 48 };
const layoutGap = { x: 190, y: 86 };
const positions = {};
let selectedNodeId = "";
let selectedEdgeIndex = -1;
let drag = null;
function nodePosition(node, index) {
const saved = layout[node.id] || {};
return {
x: Number.isFinite(saved.x) ? saved.x : 80 + (index % 4) * 220,
y: Number.isFinite(saved.y) ? saved.y : 80 + Math.floor(index / 4) * 100
};
}
nodes.forEach((node, index) => {
positions[node.id] = nodePosition(node, index);
});
function edgeEndpoints(edge) {
if (Array.isArray(edge)) return { from: edge[0], to: edge[1] };
return { from: edge.from, to: edge.to };
}
function setEdgeEndpoint(edge, key, value) {
if (Array.isArray(edge)) {
edge[key === "from" ? 0 : 1] = value;
return;
}
edge[key] = value;
}
function edgeExtras(edge) {
if (Array.isArray(edge)) {
const extra = edge[2];
return extra && typeof extra === "object" && !Array.isArray(extra) ? extra : {};
}
const out = {};
Object.entries(edge).forEach(([key, value]) => {
if (!coreEdgeKeys.has(key)) out[key] = value;
});
return out;
}
function ensureObjectEdge(index) {
const edge = edges[index];
if (!Array.isArray(edge)) return edge;
const next = { from: edge[0], to: edge[1] };
edges[index] = next;
return next;
}
function clear(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function svgEl(name, attrs) {
const el = document.createElementNS("http://www.w3.org/2000/svg", name);
Object.entries(attrs || {}).forEach(([key, value]) => el.setAttribute(key, String(value)));
return el;
}
function iconLabel(icon, type) {
const icons = {
camera: "IN",
file: "FI",
adjust: "PR",
"scan-face": "FD",
face: "FD",
"face-id": "FR",
target: "AI",
shoe: "SH",
route: "TR",
branch: "LG",
merge: "EF",
region: "RG",
activity: "AC",
filter: "PO",
overlay: "OS",
broadcast: "PB",
database: "DB",
bell: "AL",
gate: "GT",
server: "SV"
};
return icons[icon] || String(type || "?").slice(0, 2).toUpperCase();
}
function renderPalette() {
if (!paletteList) return;
clear(paletteList);
const groups = {};
Object.values(nodeCatalog).forEach((item) => {
const key = item.category || "其他";
groups[key] = groups[key] || [];
groups[key].push(item);
});
Object.keys(groups).forEach((category) => {
const group = document.createElement("div");
group.className = "graph-node-palette-group";
const title = document.createElement("div");
title.className = "graph-node-palette-category";
title.textContent = category;
group.appendChild(title);
groups[category].forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "graph-node-palette";
button.dataset.nodeType = item.type;
button.title = item.description || item.label || item.type;
const icon = document.createElement("span");
icon.className = "graph-node-palette-icon";
icon.textContent = iconLabel(item.icon, item.type);
const text = document.createElement("span");
text.className = "graph-node-palette-text";
const label = document.createElement("span");
label.textContent = item.label || item.type;
const type = document.createElement("small");
type.textContent = item.type;
text.appendChild(label);
text.appendChild(type);
button.appendChild(icon);
button.appendChild(text);
group.appendChild(button);
});
paletteList.appendChild(group);
});
}
async function loadNodeCatalog() {
if (!paletteList || !paletteList.dataset.catalogUrl || !window.fetch) return;
try {
const res = await fetch(paletteList.dataset.catalogUrl, { headers: { Accept: "application/json" } });
if (!res.ok) return;
const payload = await res.json();
if (!payload || !Array.isArray(payload.items) || payload.items.length === 0) return;
nodeCatalog = catalogFromItems(payload.items);
renderPalette();
render();
if (selectedNodeId) selectNode(selectedNodeId);
} catch (err) {
// Keep the offline catalog available when no device agent is reachable.
}
}
function nodeOptions(select, excludedId) {
clear(select);
nodes.forEach((node) => {
if (node.id === excludedId) return;
const option = document.createElement("option");
option.value = node.id;
option.textContent = node.id;
select.appendChild(option);
});
}
function anchorPoints(fromId, toId) {
const a = positions[fromId];
const b = positions[toId];
if (!a || !b) return null;
const dx = b.x - a.x;
const dy = b.y - a.y;
if (Math.abs(dy) >= Math.abs(dx)) {
if (dy >= 0) {
return {
from: { x: a.x + nodeSize.w / 2, y: a.y + nodeSize.h },
to: { x: b.x + nodeSize.w / 2, y: b.y },
vertical: true
};
}
return {
from: { x: a.x + nodeSize.w / 2, y: a.y },
to: { x: b.x + nodeSize.w / 2, y: b.y + nodeSize.h },
vertical: true
};
}
if (dx >= 0) {
return {
from: { x: a.x + nodeSize.w, y: a.y + nodeSize.h / 2 },
to: { x: b.x, y: b.y + nodeSize.h / 2 },
vertical: false
};
}
return {
from: { x: a.x, y: a.y + nodeSize.h / 2 },
to: { x: b.x + nodeSize.w, y: b.y + nodeSize.h / 2 },
vertical: false
};
}
function edgePathData(fromId, toId) {
const anchors = anchorPoints(fromId, toId);
if (!anchors) return "";
const a = anchors.from;
const b = anchors.to;
if (anchors.vertical) {
const midY = (a.y + b.y) / 2;
return `M ${a.x} ${a.y} C ${a.x} ${midY}, ${b.x} ${midY}, ${b.x} ${b.y}`;
}
const midX = (a.x + b.x) / 2;
return `M ${a.x} ${a.y} C ${midX} ${a.y}, ${midX} ${b.y}, ${b.x} ${b.y}`;
}
function updateCanvasSize() {
const padding = 90;
let maxX = 0;
let maxY = 0;
nodes.forEach((node) => {
const p = positions[node.id];
if (!p) return;
maxX = Math.max(maxX, p.x + nodeSize.w);
maxY = Math.max(maxY, p.y + nodeSize.h);
});
const contentWidth = Math.max(720, maxX + padding);
const contentHeight = Math.max(620, maxY + padding);
const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720;
svg.setAttribute("width", String(Math.max(viewportWidth, contentWidth)));
svg.setAttribute("height", String(contentHeight));
svg.style.width = `${Math.max(viewportWidth, contentWidth)}px`;
svg.style.height = `${contentHeight}px`;
svg.setAttribute("viewBox", `0 0 ${Math.max(viewportWidth, contentWidth)} ${contentHeight}`);
}
function render() {
updateCanvasSize();
clear(svg);
edges.forEach((edge, index) => {
const ep = edgeEndpoints(edge);
const d = edgePathData(ep.from, ep.to);
if (!d) return;
const hit = svgEl("path", { class: "graph-edge-hit", d: d, "data-edge-index": index });
const path = svgEl("path", {
class: "graph-edge" + (index === selectedEdgeIndex ? " selected" : ""),
d: d,
"data-edge-index": index
});
hit.addEventListener("click", () => selectEdge(index));
path.addEventListener("click", () => selectEdge(index));
svg.appendChild(hit);
svg.appendChild(path);
});
nodes.forEach((node) => {
const p = positions[node.id] || { x: 80, y: 80 };
const g = svgEl("g", {
class: "graph-node" + (node.id === selectedNodeId ? " selected" : ""),
transform: `translate(${p.x}, ${p.y})`,
"data-node-id": node.id
});
g.appendChild(svgEl("rect", { width: nodeSize.w, height: nodeSize.h, rx: 7 }));
const spec = nodeCatalog[node.type] || {};
const svgTitle = svgEl("title");
svgTitle.textContent = spec.description || node.type || node.id || "";
g.appendChild(svgTitle);
const icon = svgEl("text", { x: 12, y: 19, class: "graph-node-icon" });
icon.textContent = iconLabel(spec.icon, node.type);
g.appendChild(icon);
const title = svgEl("text", { x: 36, y: 19 });
title.textContent = node.id || "-";
g.appendChild(title);
const meta = svgEl("text", { x: 36, y: 37, class: "graph-node-type" });
meta.textContent = spec.label || node.type || "-";
g.appendChild(meta);
g.addEventListener("click", () => selectNode(node.id));
g.addEventListener("pointerdown", (event) => startDrag(event, node));
svg.appendChild(g);
});
}
function showPanel(kind) {
empty.hidden = kind !== "empty";
nodeForm.hidden = kind !== "node";
edgeForm.hidden = kind !== "edge";
}
function nodeExtras(node) {
const schemaKeys = new Set((paramSchemas[node.type] || []).map((field) => field.key));
const out = {};
Object.entries(node).forEach(([key, value]) => {
if (!coreNodeKeys.has(key) && !schemaKeys.has(key)) out[key] = value;
});
return out;
}
function fieldValueForInput(value) {
if (value === undefined || value === null) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function parseTypedValue(field, value) {
if (field.type === "boolean") return value === "true";
if (field.type === "number") {
if (String(value).trim() === "") return undefined;
const num = Number(value);
return Number.isFinite(num) ? num : undefined;
}
if (String(value).trim() === "") return undefined;
return value;
}
function createTypedField(field, value) {
const label = document.createElement("label");
const caption = document.createElement("span");
caption.textContent = field.label;
label.appendChild(caption);
let input;
if (field.type === "boolean") {
input = document.createElement("select");
[["true", "启用"], ["false", "停用"]].forEach(([val, text]) => {
const option = document.createElement("option");
option.value = val;
option.textContent = text;
input.appendChild(option);
});
input.value = value === false ? "false" : "true";
} else if (field.type === "select") {
input = document.createElement("select");
(field.options || []).forEach((item) => {
const option = document.createElement("option");
option.value = item;
option.textContent = item;
input.appendChild(option);
});
if (value !== undefined) input.value = String(value);
} else {
input = document.createElement("input");
input.type = field.type || "text";
if (field.step) input.step = field.step;
if (field.placeholder) input.placeholder = field.placeholder;
input.value = fieldValueForInput(value);
}
input.dataset.paramKey = field.key;
input.dataset.paramType = field.type || "text";
label.appendChild(input);
return label;
}
function renderTypedParamFields(node) {
clear(typedParamFields);
(paramSchemas[node.type] || []).forEach((field) => {
typedParamFields.appendChild(createTypedField(field, node[field.key]));
});
}
function applyTypedParamFields(node) {
(paramSchemas[node.type] || []).forEach((field) => {
const input = typedParamFields.querySelector(`[data-param-key="${field.key}"]`);
if (!input) return;
const parsed = parseTypedValue(field, input.value);
if (parsed === undefined) {
delete node[field.key];
} else {
node[field.key] = parsed;
}
});
}
function selectNode(id) {
selectedNodeId = id;
selectedEdgeIndex = -1;
const node = nodes.find((item) => item.id === id);
if (!node) return;
showPanel("node");
nodeForm.elements.id.value = node.id || "";
nodeForm.elements.type.value = node.type || "";
nodeForm.elements.role.value = node.role || "filter";
nodeForm.elements.enable.value = node.enable === false ? "false" : "true";
renderTypedParamFields(node);
nodeForm.elements.params_json.value = JSON.stringify(nodeExtras(node), null, 2);
nodeOptions(connectTarget, node.id);
render();
}
function selectEdge(index) {
selectedNodeId = "";
selectedEdgeIndex = index;
const edge = edges[index];
if (!edge) return;
const ep = edgeEndpoints(edge);
showPanel("edge");
nodeOptions(edgeForm.elements.from, "");
nodeOptions(edgeForm.elements.to, "");
edgeForm.elements.from.value = ep.from || "";
edgeForm.elements.to.value = ep.to || "";
edgeForm.elements.params_json.value = JSON.stringify(edgeExtras(edge), null, 2);
render();
}
function parseJSONField(text, fallback) {
const raw = text.trim();
if (raw === "") return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fallback;
return parsed;
}
function applyNodeExtras(node) {
applyTypedParamFields(node);
const extras = parseJSONField(nodeForm.elements.params_json.value, {});
Object.keys(node).forEach((key) => {
if (!coreNodeKeys.has(key) && !(paramSchemas[node.type] || []).some((field) => field.key === key)) delete node[key];
});
Object.assign(node, extras);
}
function applyEdgeExtras(index) {
const edge = ensureObjectEdge(index);
const extras = parseJSONField(edgeForm.elements.params_json.value, {});
Object.keys(edge).forEach((key) => {
if (!coreEdgeKeys.has(key)) delete edge[key];
});
Object.assign(edge, extras);
}
function bindForms() {
if (!nodeForm.dataset.bound) {
nodeForm.dataset.bound = "true";
nodeForm.addEventListener("input", function (event) {
if (event.target && event.target.dataset && event.target.dataset.paramKey) {
const node = nodes.find((item) => item.id === selectedNodeId);
if (node) applyTypedParamFields(node);
return;
}
if (event.target && event.target.name === "params_json") return;
const node = nodes.find((item) => item.id === selectedNodeId);
if (!node) return;
const oldId = node.id;
const nextId = nodeForm.elements.id.value.trim();
node.role = nodeForm.elements.role.value;
node.enable = nodeForm.elements.enable.value === "true";
if (nextId && nextId !== oldId && !nodes.some((item) => item !== node && item.id === nextId)) {
node.id = nextId;
positions[nextId] = positions[oldId] || { x: 80, y: 80 };
delete positions[oldId];
edges.forEach((edge) => {
if (edgeEndpoints(edge).from === oldId) setEdgeEndpoint(edge, "from", nextId);
if (edgeEndpoints(edge).to === oldId) setEdgeEndpoint(edge, "to", nextId);
});
selectedNodeId = nextId;
nodeOptions(connectTarget, node.id);
}
render();
});
nodeForm.elements.params_json.addEventListener("change", function () {
const node = nodes.find((item) => item.id === selectedNodeId);
if (!node) return;
try {
applyNodeExtras(node);
this.setCustomValidity("");
} catch (err) {
this.setCustomValidity("参数必须是 JSON 对象");
this.reportValidity();
}
});
}
if (!edgeForm.dataset.bound) {
edgeForm.dataset.bound = "true";
edgeForm.addEventListener("input", function (event) {
if (event.target && event.target.name === "params_json") return;
const edge = edges[selectedEdgeIndex];
if (!edge) return;
setEdgeEndpoint(edge, "from", edgeForm.elements.from.value);
setEdgeEndpoint(edge, "to", edgeForm.elements.to.value);
render();
});
edgeForm.elements.params_json.addEventListener("change", function () {
if (selectedEdgeIndex < 0) return;
try {
applyEdgeExtras(selectedEdgeIndex);
this.setCustomValidity("");
} catch (err) {
this.setCustomValidity("参数必须是 JSON 对象");
this.reportValidity();
}
});
}
}
function startDrag(event, node) {
const p = positions[node.id] || { x: 80, y: 80 };
drag = { id: node.id, startX: event.clientX, startY: event.clientY, x: p.x, y: p.y };
selectedNodeId = node.id;
selectNode(node.id);
event.preventDefault();
}
function uniqueNodeId(base) {
let out = base;
let i = 2;
while (nodes.some((node) => node.id === out)) {
out = `${base}_${i}`;
i += 1;
}
return out;
}
function hasEdge(from, to) {
return edges.some((edge) => {
const ep = edgeEndpoints(edge);
return ep.from === from && ep.to === to;
});
}
function autoLayout() {
const ids = nodes.map((node) => node.id).filter(Boolean);
const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720;
const indegree = {};
const outgoing = {};
ids.forEach((id) => {
indegree[id] = 0;
outgoing[id] = [];
});
edges.forEach((edge) => {
const ep = edgeEndpoints(edge);
if (!(ep.from in indegree) || !(ep.to in indegree)) return;
outgoing[ep.from].push(ep.to);
indegree[ep.to] += 1;
});
const queue = ids.filter((id) => indegree[id] === 0);
const level = {};
queue.forEach((id) => { level[id] = 0; });
for (let i = 0; i < queue.length; i += 1) {
const id = queue[i];
outgoing[id].forEach((next) => {
level[next] = Math.max(level[next] || 0, (level[id] || 0) + 1);
indegree[next] -= 1;
if (indegree[next] === 0) queue.push(next);
});
}
ids.forEach((id, index) => {
if (level[id] === undefined) level[id] = index;
});
const groups = {};
ids.forEach((id) => {
const key = level[id];
groups[key] = groups[key] || [];
groups[key].push(id);
});
Object.keys(groups).map(Number).sort((a, b) => a - b).forEach((levelNum) => {
const group = groups[levelNum];
const totalWidth = nodeSize.w + Math.max(0, group.length - 1) * layoutGap.x;
const startX = Math.max(60, (viewportWidth - totalWidth) / 2);
group.forEach((id, index) => {
positions[id] = {
x: startX + index * layoutGap.x,
y: 70 + levelNum * layoutGap.y
};
});
});
render();
}
if (paletteList) {
paletteList.addEventListener("click", function (event) {
const item = event.target.closest(".graph-node-palette");
if (!item || !paletteList.contains(item)) return;
const spec = nodeCatalog[item.dataset.nodeType];
if (!spec) return;
const node = JSON.parse(JSON.stringify(spec.defaults));
node.id = uniqueNodeId(node.id);
nodes.push(node);
positions[node.id] = { x: 100 + nodes.length * 20, y: 100 + nodes.length * 20 };
selectNode(node.id);
});
}
svg.addEventListener("pointermove", function (event) {
if (!drag) return;
positions[drag.id] = {
x: drag.x + event.clientX - drag.startX,
y: drag.y + event.clientY - drag.startY
};
render();
});
svg.addEventListener("pointerup", function () {
drag = null;
});
if (connectBtn) {
connectBtn.addEventListener("click", function () {
const target = connectTarget.value;
if (!selectedNodeId || !target || hasEdge(selectedNodeId, target)) return;
edges.push({ from: selectedNodeId, to: target });
selectedEdgeIndex = edges.length - 1;
selectEdge(selectedEdgeIndex);
});
}
if (deleteNodeBtn) {
deleteNodeBtn.addEventListener("click", function () {
if (!selectedNodeId) return;
const index = nodes.findIndex((node) => node.id === selectedNodeId);
if (index >= 0) nodes.splice(index, 1);
for (let i = edges.length - 1; i >= 0; i -= 1) {
const ep = edgeEndpoints(edges[i]);
if (ep.from === selectedNodeId || ep.to === selectedNodeId) edges.splice(i, 1);
}
delete positions[selectedNodeId];
selectedNodeId = "";
showPanel("empty");
render();
});
}
if (deleteEdgeBtn) {
deleteEdgeBtn.addEventListener("click", function () {
if (selectedEdgeIndex < 0) return;
edges.splice(selectedEdgeIndex, 1);
selectedEdgeIndex = -1;
showPanel("empty");
render();
});
}
if (autoLayoutBtn) {
autoLayoutBtn.addEventListener("click", autoLayout);
}
if (saveForm) {
saveForm.addEventListener("submit", function (event) {
try {
if (!nodeForm.hidden && selectedNodeId) {
const node = nodes.find((item) => item.id === selectedNodeId);
if (node) applyNodeExtras(node);
}
if (!edgeForm.hidden && selectedEdgeIndex >= 0) {
applyEdgeExtras(selectedEdgeIndex);
}
doc.ui = doc.ui || {};
doc.ui.layout = doc.ui.layout || {};
doc.ui.layout.version = 1;
doc.ui.layout.nodes = positions;
saveForm.elements.json.value = JSON.stringify(doc);
} catch (err) {
event.preventDefault();
window.alert(err && err.message ? err.message : "参数 JSON 格式不正确");
}
});
}
bindForms();
renderPalette();
loadNodeCatalog();
if (!hasSavedLayout && nodes.length > 0) {
autoLayout();
} else {
render();
}
})();