807 lines
33 KiB
JavaScript
807 lines
33 KiB
JavaScript
(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();
|
||
}
|
||
})();
|