(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(); } })();