/** * @file plc_config.js * @brief PLC 逻辑配置页 — 信号列表 + 逻辑图查看器 + 编辑器 * @details Canvas 可视化 + XML 解析/生成, 11 种门类型 */ /* ======================================================================== * PLC 逻辑配置页面 — 信号管理 + 逻辑图查看 + 新建配置编辑器 * ======================================================================== */ var PlcConfigPage = (function() { "use strict"; /* ---- Canvas roundRect polyfill (兼容旧浏览器) ---- */ if (!CanvasRenderingContext2D.prototype.roundRect) { CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { if (typeof r === 'number') r = { tl: r, tr: r, br: r, bl: r }; else if (Array.isArray(r)) r = { tl: r[0]||0, tr: r[1]||r[0]||0, br: r[2]||r[0]||0, bl: r[3]||r[0]||0 }; this.beginPath(); this.moveTo(x + r.tl, y); this.lineTo(x + w - r.tr, y); this.quadraticCurveTo(x + w, y, x + w, y + r.tr); this.lineTo(x + w, y + h - r.br); this.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h); this.lineTo(x + r.bl, y + h); this.quadraticCurveTo(x, y + h, x, y + h - r.bl); this.lineTo(x, y + r.tl); this.quadraticCurveTo(x, y, x + r.tl, y); this.closePath(); }; } /* ========== 全局状态 ========== */ var activeTab = 'signals'; // signals | viewer | editor var dcOutSignals = []; // [{no, saddr, desc}] 从 datacenter_out.xml var logicGraphs = []; // 已解析的逻辑图数组 var onlineCfgId = ''; var loadedFiles = {}; // {path: content} 记录已加载的文件 /* ======== 编辑器状态 ======== */ var editNodes = []; // [{type, id, x, y, saddr, desc, no, inPins, _lx, _ly}] var editLinks = []; // [{srcIdx, dstIdx, dstPin}] — dstPin 目标节点输入引脚序号 var editKey = ''; // 逻辑图 key var editName = ''; // 逻辑图名称 var editOnlineCfgId = ''; // 当前 online_cfgId var editSelectedNode = -1; // 当前选中节点索引,-1=无选中 var editSelectedLink = -1; // 当前选中连线索引,-1=无选中 var editWiring = null; // {fromNodeIdx, fromSide, fromPin} 当前连线起点 var editDragNode = null; // 拖拽中的节点索引 var editDragOffX = 0, editDragOffY = 0; var editDragCanvas = null; // 画布拖拽状态 var editPanX = 0, editPanY = 0; // 画布偏移 var editScale = 1.0; // 缩放 var editNextGateIdx = {}; // {type: nextIdx} 每种门类型独立序号从0开始 var editLoadedFromFile = false; // 是否从文件加载 var editLastClickTime = 0; // 上次 click 时间戳(双击检测) var editLastClickNode = -1; // 上次 click 的节点索引 /* ========== 查看器状态 ========== */ var viewPanX = 0, viewPanY = 0; var viewScale = 1.0; var viewDrag = null; // {startX, startY, panX, panY} var viewAutoFit = true; // 首次 / resize 后自动适配缩放 /* ========== 节点类型常量 ========== */ var NT_OUTPUT = 0, NT_INPUT = 1, NT_OR = 2, NT_AND = 3, NT_NOT = 4; var NT_62P = 5, NT_62D = 6, NT_RISING = 7, NT_SR = 8, NT_RS = 9, NT_FALLING = 10; var NODE_INFO = { 0: { name: '输出点', cls: 'node-out', icon: '📤', color: '#4CAF50', inPins: 1, outPins: 0, inMax: 1, outMax: 0 }, 1: { name: '输入点', cls: 'node-in', icon: '📥', color: '#2196F3', inPins: 0, outPins: 1, inMax: 0, outMax: 99 }, 2: { name: '或门 OR', cls: 'node-or', icon: '🔶', color: '#FF9800', inPins: 2, outPins: 1, inMax: 10, outMax: 10 }, 3: { name: '与门 AND', cls: 'node-and', icon: '🔷', color: '#9C27B0', inPins: 2, outPins: 1, inMax: 10, outMax: 10 }, 4: { name: '非门 NOT', cls: 'node-not', icon: '🔺', color: '#f44336', inPins: 1, outPins: 1, inMax: 1, outMax: 10 }, 5: { name: '62p延时', cls: 'node-62p', icon: '⏱️', color: '#00BCD4', inPins: 1, outPins: 1, inMax: 1, outMax: 10 }, 6: { name: '62d延时', cls: 'node-62d', icon: '⏱️', color: '#009688', inPins: 1, outPins: 1, inMax: 1, outMax: 10 }, 7: { name: '上升沿', cls: 'node-re', icon: '⬆️', color: '#E91E63', inPins: 1, outPins: 1, inMax: 1, outMax: 10 }, 8: { name: 'SR触发器', cls: 'node-sr', icon: '🔒', color: '#795548', inPins: 2, outPins: 1, inMax: 2, outMax: 10 }, 9: { name: 'RS触发器', cls: 'node-rs', icon: '🔒', color: '#607D8B', inPins: 2, outPins: 1, inMax: 2, outMax: 10 }, 10: { name: '下降沿', cls: 'node-fe', icon: '⬇️', color: '#FF5722', inPins: 1, outPins: 1, inMax: 1, outMax: 10 } }; /* ========== 工具函数 ========== */ function fmtVal(v) { return (v === null || v === undefined) ? '' : String(v); } function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /* ---- 通过节点 ID 查找信号信息 (datacenter out 中 no 即 ID) ---- */ function findSignalByNo(signalNo) { for (var i = 0; i < dcOutSignals.length; i++) { if (dcOutSignals[i].no === signalNo) return dcOutSignals[i]; } return null; } /* ---- 生成节点显示标签 (十进制ID, 含saddr/desc) ---- */ function getNodeDisplayLabel(node) { var info = NODE_INFO[node.type] || {}; if (node.type === NT_INPUT || node.type === NT_OUTPUT) { var prefix = (node.type === NT_INPUT) ? '输入' : '输出'; var decId = node.id; // id 在 parse 时已转为十进制 var sig = findSignalByNo(decId); if (sig) { return prefix + ' #' + decId + ' ("' + sig.saddr + '", "' + sig.desc + '")'; } return prefix + ' #' + decId; } // 逻辑门:显示组件序号 var gateIdx = node.id & 0xFF; return info.icon + ' ' + info.name + ' #' + gateIdx; } /* ---- 生成编辑器小标签 (简洁版,用于编辑器节点) ---- */ function getEditorNodeLabel(node) { var info = NODE_INFO[node.type] || {}; if (node.type === NT_INPUT || node.type === NT_OUTPUT) { var prefix = (node.type === NT_INPUT) ? '输入点' : '输出点'; var decId = node.id; var sig = node.saddr ? node : findSignalByNo(decId); if (sig && sig.saddr) { return info.icon + ' ' + prefix + ' #' + decId + '\n' + sig.saddr + '\n' + (sig.desc || ''); } return info.icon + ' ' + prefix + ' #' + decId; } var gateIdx = node.id & 0xFF; return info.icon + ' ' + info.name + ' #' + gateIdx; } function showCfgToast(msg, type) { // wraps global toast type = type || 'info'; var t = document.getElementById('plc-cfg-toast'); if (!t) { t = document.createElement('div'); t.id = 'plc-cfg-toast'; t.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);padding:10px 24px;border-radius:6px;z-index:99999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.3);transition:opacity 0.3s;pointer-events:none;color:#fff'; document.body.appendChild(t); } t.style.background = (type === 'err') ? '#f44336' : (type === 'ok') ? '#4CAF50' : '#333'; t.textContent = msg; t.style.opacity = '1'; clearTimeout(t._timer); t._timer = setTimeout(function() { t.style.opacity = '0'; }, 2000); } /* ========== WebSocket 文件请求 ========== */ function requestFile(path, callback) { if (loadedFiles[path]) { callback(loadedFiles[path]); return; } if (!PlcConfigPage._pending) PlcConfigPage._pending = {}; PlcConfigPage._pending[path] = callback; var sent = WsClient.send({ type: 'get_file', path: path }); if (!sent) { if (!PlcConfigPage._retryList) PlcConfigPage._retryList = []; PlcConfigPage._retryList.push(path); } } /* ========== 主渲染 ========== */ function renderTabs() { var tabs = [ { id: 'signals', label: '📋 信号列表', title: 'datacenter_out.xml — 已注册 Out 信号' }, { id: 'viewer', label: '👁️ 逻辑图查看', title: 'Reclose_logic.xml 可视化' }, { id: 'editor', label: '✏️ 新建配置', title: '新建/编辑 PLC 逻辑配置' } ]; var html = '
'; for (var i = 0; i < tabs.length; i++) { var cls = (tabs[i].id === activeTab) ? 'plc-cfg-tab active' : 'plc-cfg-tab'; html += ''; } html += '
'; return html; } function render(container) { container.innerHTML = '' + '' + renderTabs() + '
'; // Tab 切换事件 var tabEls = container.querySelectorAll('.plc-cfg-tab'); for (var i = 0; i < tabEls.length; i++) { tabEls[i].onclick = function() { activeTab = this.getAttribute('data-tab'); render(container); }; } // 渲染当前 Tab var content = document.getElementById('plc-cfg-content'); if (!content) return; switch (activeTab) { case 'signals': renderSignalsTab(content); break; case 'viewer': renderViewerTab(content); break; case 'editor': renderEditorTab(content); break; } } /* ================================================================ * Tab 1: 信号列表 — 解析 datacenter_out.xml * ================================================================ */ function renderSignalsTab(container) { container.innerHTML = '' + '
' + '' + '' + '' + '
' + '
' + '' + '' + '
序号 (no)短地址 (saddr)描述 (desc)
'; document.getElementById('plc-sig-search').oninput = function() { refreshSigTable(); }; document.getElementById('plc-sig-reload').onclick = function() { loadedFiles['config/SYSTEM/datacenter_out.xml'] = null; dcOutSignals = []; loadDcOutSignals(function() { refreshSigTable(); }); }; if (dcOutSignals.length === 0) { loadDcOutSignals(function() { refreshSigTable(); }); } else { refreshSigTable(); } } function loadDcOutSignals(cb) { requestFile('config/SYSTEM/datacenter_out.xml', function(content) { if (!content) { showToast('加载 datacenter_out.xml 失败,请先在终端执行 datacenter out', 'err'); return; } parseDcOutXml(content); if (cb) cb(); }); } function parseDcOutXml(xmlStr) { dcOutSignals = []; try { var parser = new DOMParser(); var doc = parser.parseFromString(xmlStr, 'text/xml'); var outNodes = doc.documentElement.getElementsByTagName('Out'); if (outNodes.length > 0) { var signals = outNodes[0].getElementsByTagName('Signal'); for (var i = 0; i < signals.length; i++) { dcOutSignals.push({ no: parseInt(signals[i].getAttribute('no')) || i, saddr: signals[i].getAttribute('saddr') || '', desc: signals[i].getAttribute('desc') || '' }); } } } catch(e) { showToast('解析 datacenter_out.xml 失败: ' + e.message, 'err'); } } function refreshSigTable() { var search = (document.getElementById('plc-sig-search').value || '').toLowerCase(); var data = dcOutSignals; // 过滤 var filtered = data; if (search) { filtered = data.filter(function(s) { return s.saddr.toLowerCase().indexOf(search) !== -1 || s.desc.toLowerCase().indexOf(search) !== -1 || String(s.no).indexOf(search) !== -1; }); } document.getElementById('plc-sig-count').textContent = '共 ' + filtered.length + ' / ' + data.length + ' 条'; var html = ''; for (var i = 0; i < filtered.length; i++) { var s = filtered[i]; html += '' + '' + s.no + '' + '' + escHtml(s.saddr) + '' + '' + escHtml(s.desc) + '' + ''; } document.getElementById('plc-sig-tbody').innerHTML = html; } /* ================================================================ * Tab 2: 逻辑图查看器 — Canvas 可视化 Reclose_logic.txt * ================================================================ */ function renderViewerTab(container) { container.innerHTML = '' + '
' + '' + '' + '' + '' + '' + '' + '
' + '
' + '' + '
' + '
'; // 图例 var legendHtml = '图例: '; var types = [1,0,3,2,4,5,6,7,8,9,10]; for (var i = 0; i < types.length; i++) { var info = NODE_INFO[types[i]]; legendHtml += '' + info.icon + ' ' + info.name + ''; } document.getElementById('plc-view-legend').innerHTML = legendHtml; // 绑定事件 document.getElementById('plc-view-zoomin').onclick = function() { viewScale = Math.min(3, viewScale * 1.2); drawViewer(); }; document.getElementById('plc-view-zoomout').onclick = function() { viewScale = Math.max(0.2, viewScale / 1.2); drawViewer(); }; document.getElementById('plc-view-reset').onclick = function() { viewAutoFit = true; drawViewer(); }; document.getElementById('plc-view-reload').onclick = function() { loadedFiles['config/PLC/Reclose_logic.xml'] = null; loadedFiles['config/PLC/Reclose_logic.xml.bak'] = null; logicGraphs = []; loadLogicFileAll(function() { refreshGraphSelect(); }); }; document.getElementById('plc-view-graph').onchange = function() { viewAutoFit = true; drawViewer(); }; // 画布交互 setupViewerCanvas(); if (logicGraphs.length === 0) { loadLogicFileAll(function() { refreshGraphSelect(); }); } else { refreshGraphSelect(); } } function loadLogicFileAll(cb) { var loadCount = 0; var loadedFiles2 = []; // 记录成功加载的文件名 var failedFiles = []; // 记录失败的文件名 function tryCb() { loadCount++; if (loadCount >= 2 && cb) { if (failedFiles.length > 0) { var msg = loadedFiles2.length > 0 ? '已加载: ' + loadedFiles2.join(', ') + ' | 缺失: ' + failedFiles.join(', ') : '配置文件均缺失: ' + failedFiles.join(', ') + ',请先在编辑器中生成配置'; showToast(msg, loadedFiles2.length > 0 ? 'info' : 'err'); } cb(); } } requestFile('config/PLC/Reclose_logic.xml', function(content) { if (!content) { failedFiles.push('Reclose_logic.xml'); tryCb(); return; } parseLogicFile(content, 'Reclose_logic.xml'); loadedFiles2.push('Reclose_logic.xml'); tryCb(); }); requestFile('config/PLC/Reclose_logic.xml.bak', function(content) { if (!content) { tryCb(); return; } parseLogicFile(content, 'Reclose_logic.xml.bak'); loadedFiles2.push('Reclose_logic.xml.bak'); tryCb(); }); } function loadLogicFile(cb) { requestFile('config/PLC/Reclose_logic.xml', function(content) { if (!content) { showToast('加载 Reclose_logic.xml 失败', 'err'); return; } parseLogicFile(content, 'Reclose_logic.xml'); if (cb) cb(); }); } function parseLogicFile(content, sourceName) { logicGraphs = logicGraphs.filter(function(g) { return g._source !== sourceName; }); onlineCfgId = onlineCfgId || ''; parseLogicXml(content, sourceName); } // XML → 扁平 graph,与 C++ parse_xml_comb 完全一致 function parseLogicXml(xmlStr, sourceName) { var parser = new DOMParser(); var doc = parser.parseFromString(xmlStr, 'text/xml'); var root = doc.documentElement; if (!root || root.nodeName !== 'PLCConfig') { showToast('XML 根元素不是 PLCConfig', 'err'); return; } if (sourceName.indexOf('.bak') < 0) { onlineCfgId = root.getAttribute('checksum') || ''; } var thisKey = root.getAttribute('key') || sourceName; var thisName = root.getAttribute('name') || ''; var g = { key: thisKey, name: thisName, nodes: [], links: [] }; var GTM = {'INPUT':1,'OUTPUT':0,'OR':2,'AND':3,'NOT':4,'T62P':5,'T62D':6,'RISING':7,'SR':8,'RS':9,'FALLING':10}; var sigNodeMap = {}; function ensureSig(ioType, no, x, y) { var k = ioType + ':' + no; if (sigNodeMap[k] !== undefined) { if (x !== undefined) { g.nodes[sigNodeMap[k]].x = x; g.nodes[sigNodeMap[k]].y = y; } return sigNodeMap[k]; } g.nodes.push({ type:ioType, id:no, x:x||0, y:y||0 }); sigNodeMap[k] = g.nodes.length - 1; return g.nodes.length - 1; } function ensureGate(type, id, x, y, inPins, delayMs) { for (var i = 0; i < g.nodes.length; i++) if (g.nodes[i].type === type && (g.nodes[i].id & 0xFF) === (id & 0xFF)) return i; var nd = { type:type, id:id, x:x||0, y:y||0 }; if (inPins) nd.inPins = inPins; if (delayMs) nd.delayMs = delayMs; g.nodes.push(nd); return g.nodes.length - 1; } function parseComb(combEl) { var ge = combEl.firstElementChild; while (ge && ge.nodeName !== 'Gate') ge = ge.nextElementSibling; if (!ge) return -1; var t = GTM[ge.getAttribute('type')] || 0; var gid = parseInt(ge.getAttribute('id')); var gi = ensureGate(t, gid, parseInt(ge.getAttribute('x')), parseInt(ge.getAttribute('y')), ge.getAttribute('inPins') ? parseInt(ge.getAttribute('inPins')) : null, ge.getAttribute('delayMs') ? parseInt(ge.getAttribute('delayMs')) : null); // 读取 Comb id var combId = combEl.getAttribute('id'); if (combId) g.nodes[gi].combId = parseInt(combId); // 读取 SR/RS 的 R/S 属性 var rAttr = ge.getAttribute('R'); var sAttr = ge.getAttribute('S'); if (rAttr) g.nodes[gi].rsR = rAttr; if (sAttr) g.nodes[gi].rsS = sAttr; var c = combEl.firstElementChild; while (c) { if (c.nodeName === 'Comb') { var upi = parseComb(c); if (upi >= 0) g.links.push({ src_type:g.nodes[upi].type, src_id:g.nodes[upi].id, dst_type:t, dst_id:gid }); } c = c.nextElementSibling; } var ie = combEl.firstElementChild; while (ie && ie.nodeName !== 'Inputs') ie = ie.nextElementSibling; if (ie) { var s = ie.firstElementChild; while (s) { if (s.nodeName === 'Signal') { var no = parseInt(s.getAttribute('no')); ensureSig(NT_INPUT, no, parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y'))); g.links.push({ src_type:NT_INPUT, src_id:no, dst_type:t, dst_id:gid }); } s = s.nextElementSibling; } } var oe = combEl.firstElementChild; while (oe && oe.nodeName !== 'Outputs') oe = oe.nextElementSibling; if (oe) { var s = oe.firstElementChild; while (s) { if (s.nodeName === 'Signal') { var no = parseInt(s.getAttribute('no')); ensureSig(NT_OUTPUT, no, parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y'))); g.links.push({ src_type:t, src_id:gid, dst_type:NT_OUTPUT, dst_id:no }); } s = s.nextElementSibling; } } return gi; } var chains = root.getElementsByTagName('Chain'); for (var ci = 0; ci < chains.length; ci++) { var chain = chains[ci]; var c = chain.firstElementChild; while (c) { if (c.nodeName === 'Inputs') { var s = c.firstElementChild; while (s) { if (s.nodeName === 'Signal') ensureSig(NT_INPUT, parseInt(s.getAttribute('no')), parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y'))); s = s.nextElementSibling; } } if (c.nodeName === 'Outputs') { var s = c.firstElementChild; while (s) { if (s.nodeName === 'Signal') ensureSig(NT_OUTPUT, parseInt(s.getAttribute('no')), parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y'))); s = s.nextElementSibling; } } if (c.nodeName === 'Comb') parseComb(c); if (c.nodeName === 'Gate') { var gt = GTM[c.getAttribute('type')] || 0; var giChain = ensureGate(gt, parseInt(c.getAttribute('id')), parseInt(c.getAttribute('x')), parseInt(c.getAttribute('y'))); var rAttrC = c.getAttribute('R'); var sAttrC = c.getAttribute('S'); var dmsC = c.getAttribute('delayMs'); if (rAttrC) g.nodes[giChain].rsR = rAttrC; if (sAttrC) g.nodes[giChain].rsS = sAttrC; if (dmsC) g.nodes[giChain].delayMs = parseInt(dmsC); } c = c.nextElementSibling; } } // Comb链: Comb Gate → 链级 Gate (一个Comb一根线) + 链级 Gate → Outputs // 简单链: Inputs → Gate → Outputs for (var ci2 = 0; ci2 < chains.length; ci2++) { var chain2 = chains[ci2]; var hasComb = false; var c3 = chain2.firstElementChild; while (c3) { if (c3.nodeName === 'Comb') { hasComb = true; break; } c3 = c3.nextElementSibling; } var ge2 = null, ie2 = null, oe2 = null; c3 = chain2.firstElementChild; while (c3) { if (c3.nodeName === 'Gate') ge2 = c3; if (c3.nodeName === 'Inputs') ie2 = c3; if (c3.nodeName === 'Outputs') oe2 = c3; c3 = c3.nextElementSibling; } if (hasComb && ge2) { // 链级 Comb → 取其 Gate → 链级 Gate c3 = chain2.firstElementChild; while (c3) { if (c3.nodeName === 'Comb') { var cg = c3.firstElementChild; while (cg && cg.nodeName !== 'Gate') cg = cg.nextElementSibling; if (cg) { var ct = GTM[cg.getAttribute('type')] || 0; var ci = parseInt(cg.getAttribute('id')); g.links.push({ src_type:ct, src_id:ci, dst_type:GTM[ge2.getAttribute('type')] || 0, dst_id:parseInt(ge2.getAttribute('id')) }); } } c3 = c3.nextElementSibling; } // 链级 Inputs(如 RS/SR 直接连 Input 信号)→ 链级 Gate if (ie2) { var s = ie2.firstElementChild; while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:NT_INPUT, src_id:parseInt(s.getAttribute('no')), dst_type:GTM[ge2.getAttribute('type')] || 0, dst_id:parseInt(ge2.getAttribute('id')) }); s = s.nextElementSibling; } } // RS/SR 的 R/S 属性引用的 Input 信号(无 块时也需创建链接) if (ge2) { var gtCS = GTM[ge2.getAttribute('type')] || 0; if (gtCS === NT_SR || gtCS === NT_RS) { var sAttrCS = ge2.getAttribute('S'); var rAttrCS = ge2.getAttribute('R'); if (sAttrCS && sAttrCS.indexOf('Input#') === 0) { var sNo = parseInt(sAttrCS.substring(6)); ensureSig(NT_INPUT, sNo, 0, 0); g.links.push({ src_type:NT_INPUT, src_id:sNo, dst_type:gtCS, dst_id:parseInt(ge2.getAttribute('id')) }); } if (rAttrCS && rAttrCS.indexOf('Input#') === 0) { var rNo = parseInt(rAttrCS.substring(6)); ensureSig(NT_INPUT, rNo, 0, 0); g.links.push({ src_type:NT_INPUT, src_id:rNo, dst_type:gtCS, dst_id:parseInt(ge2.getAttribute('id')) }); } } } if (oe2) { var s = oe2.firstElementChild; while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:GTM[ge2.getAttribute('type')] || 0, src_id:parseInt(ge2.getAttribute('id')), dst_type:NT_OUTPUT, dst_id:parseInt(s.getAttribute('no')) }); s = s.nextElementSibling; } } } else if (!hasComb && ge2) { var gt = GTM[ge2.getAttribute('type')] || 0; var gid = parseInt(ge2.getAttribute('id')); if (ie2) { var s = ie2.firstElementChild; while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:NT_INPUT, src_id:parseInt(s.getAttribute('no')), dst_type:gt, dst_id:gid }); s = s.nextElementSibling; } } if (oe2) { var s = oe2.firstElementChild; while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:gt, src_id:gid, dst_type:NT_OUTPUT, dst_id:parseInt(s.getAttribute('no')) }); s = s.nextElementSibling; } } } } // 最终遍:为 SR/RS 节点修复 dstPin for (var ni = 0; ni < g.nodes.length; ni++) { var nd = g.nodes[ni]; if (nd.type !== NT_SR && nd.type !== NT_RS) continue; var sSrc = nd.rsS || ''; var rSrc = nd.rsR || ''; if (!sSrc && !rSrc) continue; // 遍历所有到该节点的链路 for (var lj = 0; lj < g.links.length; lj++) { var lk = g.links[lj]; if (lk.dst_type !== nd.type || (lk.dst_id & 0xFF) !== (nd.id & 0xFF)) continue; var srcDesc = ''; var sN = null; for (var sn = 0; sn < g.nodes.length; sn++) { if (g.nodes[sn].type === lk.src_type && (g.nodes[sn].type <= 1 ? g.nodes[sn].id === lk.src_id : (g.nodes[sn].id & 0xFF) === (lk.src_id & 0xFF))) { sN = g.nodes[sn]; break; } } if (!sN) continue; if (sN.type === NT_INPUT) srcDesc = 'Input#' + sN.id; else srcDesc = 'Comb#' + (sN.combId !== undefined ? sN.combId : (sN.id & 0xFF)); if (nd.type === NT_SR) { if (sSrc && srcDesc === sSrc) lk.dstPin = 0; else if (rSrc && srcDesc === rSrc) lk.dstPin = 1; } else { if (sSrc && srcDesc === sSrc) lk.dstPin = 1; else if (rSrc && srcDesc === rSrc) lk.dstPin = 0; } } } g._source = sourceName; logicGraphs.push(g); } function refreshGraphSelect() { var sel = document.getElementById('plc-view-graph'); if (!sel) return; sel.innerHTML = ''; for (var i = 0; i < logicGraphs.length; i++) { var g = logicGraphs[i]; var bakTag = (g._source && g._source.indexOf(".bak") >= 0) ? " [备份]" : ""; var label = g.key + " — " + (g.name || "(未命名)") + bakTag + " (" + g.nodes.length + "节点 " + g.links.length + "连线)"; sel.innerHTML += ''; } if (logicGraphs.length > 0) { sel.value = '0'; setTimeout(function() { drawViewer(); }, 100); } } /* ---- 查看器 Canvas ---- */ function setupViewerCanvas() { var wrap = document.getElementById('plc-view-canvas-wrap'); var canvas = document.getElementById('plc-view-canvas'); if (!wrap || !canvas) return; function resize() { canvas.width = wrap.clientWidth; canvas.height = wrap.clientHeight - 4; viewAutoFit = true; drawViewer(); } resize(); window.addEventListener('resize', function() { resize(); }); // 鼠标事件 canvas.onmousedown = function(e) { viewDrag = { startX: e.clientX, startY: e.clientY, panX: viewPanX, panY: viewPanY }; }; canvas.onmousemove = function(e) { if (!viewDrag) return; viewPanX = viewDrag.panX + (e.clientX - viewDrag.startX); viewPanY = viewDrag.panY + (e.clientY - viewDrag.startY); drawViewer(); }; canvas.onmouseup = function() { viewDrag = null; }; canvas.onmouseleave = function() { viewDrag = null; }; canvas.onwheel = function(e) { e.preventDefault(); var delta = e.deltaY > 0 ? 0.9 : 1.1; var newScale = Math.max(0.2, Math.min(3, viewScale * delta)); // 以鼠标位置为中心缩放 var rect = canvas.getBoundingClientRect(); var mx = e.clientX - rect.left; var my = e.clientY - rect.top; viewPanX = mx - (mx - viewPanX) * (newScale / viewScale); viewPanY = my - (my - viewPanY) * (newScale / viewScale); viewScale = newScale; drawViewer(); }; } function drawViewer() { var canvas = document.getElementById('plc-view-canvas'); if (!canvas) return; var ctx = canvas.getContext('2d'); var W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, W, H); var sel = document.getElementById('plc-view-graph'); if (!sel || sel.value === '') { ctx.fillStyle = '#888'; ctx.font = '16px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('请选择一个逻辑图', W/2, H/2); return; } var g = logicGraphs[parseInt(sel.value)]; if (!g) return; // 使用文件中保存的 x,y 坐标(与编辑器布局一致) var viewNodes = []; var bMinX = Infinity, bMaxX = -Infinity, bMinY = Infinity, bMaxY = -Infinity; for (var vi = 0; vi < g.nodes.length; vi++) { var vn = g.nodes[vi]; var vx = vn.x, vy = vn.y; if (vx < bMinX) bMinX = vx; if (vx > bMaxX) bMaxX = vx; if (vy < bMinY) bMinY = vy; if (vy > bMaxY) bMaxY = vy; var vsig = findSignalByNo(vn.id); viewNodes.push({ type: vn.type, id: vn.id, _lx: vx, _ly: vy, saddr: (vsig && vsig.saddr) || '', desc: (vsig && vsig.desc) || '', no: vn.id }); } // 首次绘制 / resize 后自动适配缩放 if (viewAutoFit) { viewAutoFit = false; var cw2 = bMaxX - bMinX, ch2 = bMaxY - bMinY; if (cw2 <= 0) cw2 = 800; if (ch2 <= 0) ch2 = 600; var pad = 80; viewScale = Math.min((W - pad * 2) / cw2, (H - pad * 2) / ch2, 1.2); viewPanX = W / 2 - (bMinX + cw2 / 2) * viewScale; viewPanY = H / 2 - (bMinY + ch2 / 2) * viewScale; } // 链路 —— 修复:首个命中而非最后命中(避免同名门 ID 歧义) var viewLinks = []; for (var vlj = 0; vlj < g.links.length; vlj++) { var vl = g.links[vlj]; var vls = -1, vld = -1; for (var vlk = 0; vlk < g.nodes.length; vlk++) { var vln = g.nodes[vlk]; if (vls < 0 && vln.type === vl.src_type && (vln.type <= 1 ? vln.id === vl.src_id : (vln.id & 0xFF) === (vl.src_id & 0xFF))) vls = vlk; if (vld < 0 && vln.type === vl.dst_type && (vln.type <= 1 ? vln.id === vl.dst_id : (vln.id & 0xFF) === (vl.dst_id & 0xFF))) vld = vlk; } if (vls >= 0 && vld >= 0) viewLinks.push({ srcIdx: vls, dstIdx: vld, dstPin: vl.dstPin || 0 }); } // 网格 ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; var gridSize = 50; var startGx = Math.floor(-viewPanX / viewScale / gridSize) * gridSize; var startGy = Math.floor(-viewPanY / viewScale / gridSize) * gridSize; ctx.save(); ctx.translate(viewPanX, viewPanY); ctx.scale(viewScale, viewScale); for (var gx = startGx - gridSize; gx < startGx + W/viewScale + gridSize; gx += gridSize) { ctx.beginPath(); ctx.moveTo(gx, startGy - gridSize); ctx.lineTo(gx, startGy + H/viewScale + gridSize); ctx.stroke(); } for (var gy = startGy - gridSize; gy < startGy + H/viewScale + gridSize; gy += gridSize) { ctx.beginPath(); ctx.moveTo(startGx - gridSize, gy); ctx.lineTo(startGx + W/viewScale + gridSize, gy); ctx.stroke(); } drawGraphContent(ctx, viewNodes, viewLinks, -1, -1, null); ctx.restore(); } function calcLayout(g) { // 自动布局:每条链路 (输入→运算→输出) 构成一条横向数据流链 // 节点类型常量复用的全局 NODE_INFO var layout = {}; // 1. 构建邻接关系,按连线确定连通分量(每条链) var adjOut = {}; for (var i = 0; i < g.nodes.length; i++) adjOut[i] = []; var adjIn = {}; for (var i = 0; i < g.nodes.length; i++) adjIn[i] = []; for (var j = 0; j < g.links.length; j++) { var l = g.links[j]; var si = -1, di = -1; for (var k = 0; k < g.nodes.length; k++) { var n = g.nodes[k]; if (si < 0 && n.type === l.src_type && (n.type <= 1 ? n.id === l.src_id : (n.id & 0xFF) === (l.src_id & 0xFF))) si = k; if (di < 0 && n.type === l.dst_type && (n.type <= 1 ? n.id === l.dst_id : (n.id & 0xFF) === (l.dst_id & 0xFF))) di = k; } if (si >= 0 && di >= 0) { adjOut[si].push(di); adjIn[di].push(si); } } // BFS 找连通分量 var visited = {}; var chains = []; for (var idx = 0; idx < g.nodes.length; idx++) { if (visited[idx]) continue; var chain = []; var queue = [idx]; visited[idx] = true; while (queue.length > 0) { var cur = queue.shift(); chain.push(cur); var nbrs = adjOut[cur].concat(adjIn[cur]); for (var nb = 0; nb < nbrs.length; nb++) { if (!visited[nbrs[nb]]) { visited[nbrs[nb]] = true; queue.push(nbrs[nb]); } } } chains.push(chain); } // 2. 计算每条链的实际竖向高度(含节点+间距) var NODE_IO_H = 80; // 输入/输出节点高度 var NODE_GATE_H = 66; // 逻辑门节点高度 var ITEM_GAP = 28; // 同列节点纵向间距 function calcChainPixels(chain) { var cin = 0, cg = 0, cout = 0; for (var i = 0; i < chain.length; i++) { var nd = g.nodes[chain[i]]; if (nd.type === NT_INPUT) cin++; else if (nd.type === NT_OUTPUT) cout++; else cg++; } // 每列独立高度 var hIn = cin > 0 ? cin * NODE_IO_H + (cin - 1) * ITEM_GAP : 0; var hGate = cg > 0 ? cg * NODE_GATE_H + (cg - 1) * ITEM_GAP : 0; var hOut = cout > 0 ? cout * NODE_IO_H + (cout - 1) * ITEM_GAP : 0; return Math.max(hIn, hGate, hOut, 60); // 至少 60px 避免空链过扁 } var CHAIN_GAP = 80; // 链间纵向间距 // 累计 Y 偏移 var chainHeights = []; var totalH = 0; for (var ci = 0; ci < chains.length; ci++) { var h = calcChainPixels(chains[ci]); chainHeights.push(h); totalH += h; } totalH += (chains.length - 1) * CHAIN_GAP; // 使链条在 Y 轴居中:startY = -(totalH / 2) + (firstHalfHeight/2) var startY = -totalH / 2; var curY = startY; // 3. 列布局 (输入 | 运算 | 输出) var COL_INPUT = -420; var COL_GATE = 0; var COL_OUTPUT = 420; for (var ci2 = 0; ci2 < chains.length; ci2++) { var chain = chains[ci2]; var ch = chainHeights[ci2]; var cy = curY + ch / 2; // 链中心 Y // 分类节点 var cInputs = [], cGates = [], cOutputs = []; for (var n = 0; n < chain.length; n++) { var nd = g.nodes[chain[n]]; if (nd.type === NT_INPUT) cInputs.push(chain[n]); else if (nd.type === NT_OUTPUT) cOutputs.push(chain[n]); else cGates.push(chain[n]); } // 计算每列起始 Y(使该列节点在链高度内居中) function colTop(count, itemH) { if (count === 0) return cy; var colH = count * itemH + (count - 1) * ITEM_GAP; return cy - colH / 2 + itemH / 2; } var topIn = colTop(cInputs.length, NODE_IO_H); var topGate = colTop(cGates.length, NODE_GATE_H); var topOut = colTop(cOutputs.length, NODE_IO_H); for (var a = 0; a < cInputs.length; a++) layout[cInputs[a]] = { x: COL_INPUT, y: topIn + a * (NODE_IO_H + ITEM_GAP) }; for (var b = 0; b < cGates.length; b++) layout[cGates[b]] = { x: COL_GATE, y: topGate + b * (NODE_GATE_H + ITEM_GAP) }; for (var c = 0; c < cOutputs.length; c++) layout[cOutputs[c]] = { x: COL_OUTPUT, y: topOut + c * (NODE_IO_H + ITEM_GAP) }; curY += ch + CHAIN_GAP; } // 暴露边界信息供 canvas 自适应 layout._bounds = { minX: COL_INPUT - 130, maxX: COL_OUTPUT + 130, minY: startY - 40, maxY: startY + totalH + 40 }; return layout; } /* ---- 通用绘制函数(编辑器 + 查看器共用) ---- */ function drawGraphContent(ctx, nodes, links, selectedIdx, selectedLinkIdx, wiring) { // 节点渲染尺寸常量 var IO_W = 250, IO_H = 80; // 输入/输出 var GATE_W = 170, GATE_H = 66; // 逻辑门 var CORNER_R = 12; var SHADOW_OFF = 3; var LEFT_BAR_W = 8; // ---- 连线层 ---- for (var i = 0; i < links.length; i++) { var l = links[i]; if (l.srcIdx >= nodes.length || l.dstIdx >= nodes.length) continue; var sn = nodes[l.srcIdx], dn = nodes[l.dstIdx]; var srcIsIO = (sn.type === NT_OUTPUT || sn.type === NT_INPUT); var dstIsIO = (dn.type === NT_OUTPUT || dn.type === NT_INPUT); var dstIsSRRS = (dn.type === NT_SR || dn.type === NT_RS); var sW = srcIsIO ? IO_W : GATE_W, dW = dstIsIO ? IO_W : GATE_W; var dH = dstIsIO ? IO_H : (dstIsSRRS ? 86 : GATE_H); var x1 = sn._lx + sW/2; var y1 = sn._ly; var dstInP = getNodeInPins(dn); var effPinIdx = (l.dstPin !== undefined) ? l.dstPin : 0; var y2 = dn._ly - dH/2 + dH * (effPinIdx + 1) / (dstIsSRRS ? 3 : (dstInP + 1)); var x2 = dn._lx - dW/2; var isActive = wiring && wiring.fromIdx === l.srcIdx; var isLinkSelected = (selectedLinkIdx === i); var midX = (x1 + x2) / 2; var lineAlpha = (isActive || isLinkSelected) ? 1 : 0.6; var lineW = (isActive || isLinkSelected) ? 3 : 2; var lineColor = isLinkSelected ? '#FF5722' : (isActive ? '#FFC107' : 'rgba(180,200,220,' + lineAlpha + ')'); // 发光效果 (阴影线) ctx.beginPath(); ctx.strokeStyle = isLinkSelected ? 'rgba(255,87,34,0.3)' : 'rgba(255,200,0,' + (lineAlpha * 0.2) + ')'; ctx.lineWidth = lineW + 4; ctx.moveTo(x1, y1); ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2); ctx.stroke(); // 主线 ctx.beginPath(); ctx.strokeStyle = lineColor; ctx.lineWidth = lineW; ctx.moveTo(x1, y1); ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2); ctx.stroke(); // 箭头 (指向目标) ctx.fillStyle = lineColor; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - 7, y2 - 3.5); ctx.lineTo(x2 - 7, y2 + 3.5); ctx.closePath(); ctx.fill(); // 选中连线删除按钮标记(×) if (isLinkSelected) { var labelX = (x1 + x2) / 2, labelY = (y1 + y2) / 2 - 18; ctx.fillStyle = '#FF5722'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('✕ 右键删除', labelX, labelY); } } // 临时连线(仅编辑器) if (wiring && wiring.fromIdx < nodes.length) { var fn = nodes[wiring.fromIdx]; var fnIsIO = (fn.type === NT_OUTPUT || fn.type === NT_INPUT); var fW = fnIsIO ? IO_W : GATE_W; var fx = (wiring.fromSide === 'out') ? fn._lx + fW/2 : fn._lx - fW/2; var fy = fn._ly; ctx.beginPath(); ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.moveTo(fx, fy); ctx.lineTo(fx + (wiring.fromSide === 'out' ? 100 : -100), fy); ctx.stroke(); ctx.setLineDash([]); } // ---- 节点层 ---- for (var j = 0; j < nodes.length; j++) { var n = nodes[j]; var info = NODE_INFO[n.type] || { name: '?', color: '#555', icon: '?' }; var isIO = (n.type === NT_OUTPUT || n.type === NT_INPUT); var inP = getNodeInPins(n); var nw = isIO ? IO_W : GATE_W; var nh = isIO ? IO_H : (GATE_H + Math.max(0, inP - 2) * 14); var x = n._lx, y = n._ly; var halfW = nw/2, halfH = nh/2; var isWiringSrc = wiring && wiring.fromIdx === j; var isSelected = (selectedIdx === j); // 发光描边宽度 var glowW = (isSelected || isWiringSrc) ? 3 : 1; // --- 阴影 --- ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.roundRect(x - halfW + SHADOW_OFF, y - halfH + SHADOW_OFF, nw, nh, CORNER_R); ctx.fill(); // --- 主体渐变 --- var grad = ctx.createLinearGradient(x - halfW, y - halfH, x - halfW, y + halfH); if (isSelected) { grad.addColorStop(0, '#3b3b6b'); grad.addColorStop(1, '#282848'); } else if (isWiringSrc) { grad.addColorStop(0, '#3c3c50'); grad.addColorStop(1, '#252538'); } else { grad.addColorStop(0, '#2e2e4a'); grad.addColorStop(1, '#1e1e36'); } ctx.fillStyle = grad; ctx.beginPath(); ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R); ctx.fill(); // --- 描边 --- ctx.strokeStyle = isSelected ? '#FFD700' : (isWiringSrc ? '#FFC107' : info.color); ctx.lineWidth = glowW; ctx.beginPath(); ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R); ctx.stroke(); // --- 左侧色条 --- ctx.fillStyle = info.color; ctx.beginPath(); ctx.roundRect(x - halfW, y - halfH, LEFT_BAR_W, nh, [CORNER_R, 0, 0, CORNER_R]); ctx.fill(); // --- 顶部类型标签 --- if (info.name) { var labelBgX = x - halfW + LEFT_BAR_W; var labelBgW = info.name.length * 7.5 + 18; var labelBgY = y - halfH + 2; var labelBgH = 16; ctx.fillStyle = info.color; ctx.globalAlpha = 0.25; ctx.fillRect(labelBgX, labelBgY, Math.min(labelBgW, nw - LEFT_BAR_W - 4), labelBgH); ctx.globalAlpha = 1; ctx.fillStyle = isIO ? '#fff' : info.color; ctx.font = '9px -apple-system, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(info.name, labelBgX + 4, labelBgY + 2.5); } // --- 文本内容 --- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if (isIO) { var decId = n.id; var sig = n.saddr ? n : findSignalByNo(decId); var prefix = (n.type === NT_INPUT) ? '📥' : '📤'; var roleLabel = (n.type === NT_INPUT) ? '输入点' : '输出点'; // ID 行 ctx.fillStyle = '#e0e0e0'; ctx.font = 'bold 12px -apple-system, sans-serif'; ctx.fillText(prefix + ' ' + roleLabel + ' #' + decId, x, y - 18); // saddr 行 ctx.fillStyle = '#aaa'; ctx.font = '10px monospace'; var saddrText = (sig && sig.saddr) ? sig.saddr : '—'; ctx.fillText(saddrText, x, y); // desc 行 var descText = (sig && sig.desc) ? sig.desc : ''; if (descText) { ctx.fillStyle = '#888'; ctx.font = '9px -apple-system, sans-serif'; var truncated = descText.length > 18 ? descText.substring(0, 16) + '…' : descText; ctx.fillText(truncated, x, y + 14); } } else { // 逻辑门 var gateIdx = n.id & 0xFF; var realIn = 0, realOut = 0; for (var li = 0; li < links.length; li++) { if (links[li].dstIdx === j) realIn++; if (links[li].srcIdx === j) realOut++; } // 中部:名称 + 序号 ctx.fillStyle = '#e8e8e8'; ctx.font = 'bold 11px -apple-system, sans-serif'; var titleText = info.icon + ' #' + gateIdx; if (n.type === NT_62P || n.type === NT_62D) { titleText += ' (' + (n.delayMs || 1000) + 'ms)'; } ctx.fillText(titleText, x, y - 6); // 连接数 / 延时信息 ctx.fillStyle = '#999'; ctx.font = '9px monospace'; if (n.type === NT_SR || n.type === NT_RS) { ctx.fillText(realIn + '入 ' + realOut + '出', x, y + 14); } else { ctx.fillText(realIn + '入 ' + realOut + '出', x, y + 14); } } // --- 输入引脚 --- if (inP > 0) { var isSRRS = (n.type === NT_SR || n.type === NT_RS); var renderPins = isSRRS ? 2 : (isIO ? inP : 1); for (var p = 0; p < renderPins; p++) { var py = y - nh/2 + nh * (p + 1) / (isSRRS ? 3 : (inP + 1)); // 外圈 ctx.fillStyle = '#2e2e4a'; ctx.beginPath(); ctx.arc(x - halfW, py, 7, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = isSRRS ? (p === 0 ? '#FFC107' : '#FF5722') : '#4CAF50'; ctx.lineWidth = 2; ctx.stroke(); // 内芯 ctx.fillStyle = isSRRS ? (p === 0 ? '#FFC107' : '#FF5722') : '#4CAF50'; ctx.beginPath(); ctx.arc(x - halfW, py, 3.5, 0, Math.PI * 2); ctx.fill(); // SR/RS 引脚标签 if (isSRRS) { ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; var srl = (n.type === NT_SR) ? ['S', 'R'] : ['R', 'S']; ctx.fillText(srl[p], x - halfW - 12, py); } } } // --- 输出引脚 --- if (info.outPins > 0) { // 外圈 ctx.fillStyle = '#2e2e4a'; ctx.beginPath(); ctx.arc(x + halfW, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#FF9800'; ctx.lineWidth = 2; ctx.stroke(); // 内芯 ctx.fillStyle = '#FF9800'; ctx.beginPath(); ctx.arc(x + halfW, y, 3.5, 0, Math.PI * 2); ctx.fill(); } } } function renderEditorTab(container) { editNodes = []; editLinks = []; editKey = ''; editName = ''; editOnlineCfgId = ''; editSelectedNode = -1; editSelectedLink = -1; editWiring = null; editDragNode = null; editPanX = 0; editPanY = 0; editScale = 1.0; editLoadedFromFile = false; editNextGateIdx = {}; container.innerHTML = '' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '💡 双击模块可修改配置 | 单击选中/引脚连线 | 右键删除连线 | 滚轮缩放 | 按住空白拖动画布' + '
'; // 工具栏事件 document.getElementById('plc-edit-key').oninput = function() { editKey = this.value; }; document.getElementById('plc-edit-name').oninput = function() { editName = this.value; }; document.getElementById('plc-edit-add-in').onclick = function() { showAddInputModal(); }; document.getElementById('plc-edit-add-out').onclick = function() { showAddOutputModal(); }; document.getElementById('plc-edit-add-gate').onclick = function() { showAddGateModal(); }; document.getElementById('plc-edit-zoomin').onclick = function() { editScale = Math.min(3, editScale * 1.2); drawEditor(); }; document.getElementById('plc-edit-zoomout').onclick = function() { editScale = Math.max(0.2, editScale / 1.2); drawEditor(); }; document.getElementById('plc-edit-del').onclick = function() { deleteSelectedNode(); }; document.getElementById('plc-edit-modify').onclick = function() { modifySelectedNode(); }; document.getElementById('plc-edit-generate').onclick = function() { generateAndSaveConfig(); }; setupEditorCanvas(); drawEditor(); // 加载当前 Reclose_logic.xml 为编辑起点 function initEditor() { loadLogicFile(function() { if (logicGraphs.length > 0) { // 优先使用非 bak 文件的最新配置 var g = null; for (var gi = 0; gi < logicGraphs.length; gi++) { if (!logicGraphs[gi]._source || logicGraphs[gi]._source.indexOf('.bak') < 0) { g = logicGraphs[gi]; break; } } if (!g && logicGraphs.length > 0) g = logicGraphs[0]; if (g) { editKey = g.key; editName = g.name || ''; editOnlineCfgId = onlineCfgId; // 复制节点到编辑器 for (var i = 0; i < g.nodes.length; i++) { var n = g.nodes[i]; var newNode = { type: n.type, id: n.id, x: n.x, y: n.y, _lx: n.x, _ly: n.y, saddr: '', desc: '', no: n.id }; // 查找信号信息 var sig = findSignalByNo(n.id); if (sig) { newNode.saddr = sig.saddr; newNode.desc = sig.desc; } // 逻辑门 inPins — 优先使用文件中保存的配置 if (n.type !== NT_INPUT && n.type !== NT_OUTPUT) { newNode.inPins = (n.inPins !== undefined) ? n.inPins : ((NODE_INFO[n.type] || {}).inPins || 2); } // 延时字段 if (n.delayMs !== undefined) newNode.delayMs = n.delayMs; // Comb id 和 RS 信息 if (n.combId !== undefined) newNode.combId = n.combId; if (n.rsR) newNode.rsR = n.rsR; if (n.rsS) newNode.rsS = n.rsS; editNodes.push(newNode); } // 复制链路(按节点在新数组中的索引) for (var j = 0; j < g.links.length; j++) { var l = g.links[j]; var si = findEditNodeIndex(l.src_type, l.src_id); var di = findEditNodeIndex(l.dst_type, l.dst_id); if (si >= 0 && di >= 0) { var el = { srcIdx: si, dstIdx: di }; if (l.dstPin !== undefined) el.dstPin = l.dstPin; editLinks.push(el); } } editLoadedFromFile = true; // 按类型统计最大序号,后续新增时每种类型独立递增 var maxByType = {}; for (var di = 0; di < editNodes.length; di++) { var dn = editNodes[di]; if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue; var idx = dn.id & 0xFF; if (idx >= (maxByType[dn.type] || -1)) maxByType[dn.type] = idx; } for (var t in maxByType) { editNextGateIdx[t] = maxByType[t] + 1; } // 消除已有的重复 type+id 并基于每类型独立序号分配新 ID var seenIds = {}; for (var di = 0; di < editNodes.length; di++) { var dn = editNodes[di]; if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue; var key2 = dn.type + ':' + (dn.id & 0xFF); if (seenIds[key2]) { var next = editNextGateIdx[dn.type] || 0; editNodes[di].id = (dn.id & ~0xFF) | next; editNextGateIdx[dn.type] = next + 1; } else { seenIds[key2] = true; } } // 更新输入框 var keyEl = document.getElementById('plc-edit-key'); var nameEl = document.getElementById('plc-edit-name'); if (keyEl) keyEl.value = editKey; if (nameEl) nameEl.value = editName; } else { editKey = String(Date.now()); editName = ''; var keyEl = document.getElementById('plc-edit-key'); if (keyEl) keyEl.value = editKey; } drawEditor(); } }); } if (dcOutSignals.length === 0) { loadDcOutSignals(function() { initEditor(); }); } else { initEditor(); } } function findEditNodeIndex(type, id) { for (var i = 0; i < editNodes.length; i++) { var n = editNodes[i]; if (n.type === type) { if (type <= 1 && n.id === id) return i; if (type > 1 && (n.id & 0xFF) === (id & 0xFF)) return i; } } return -1; } function deleteSelectedNode() { if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) { showToast('请先在画布上双击或单击选中一个模块', 'err'); return; } var node = editNodes[editSelectedNode]; var idx = editSelectedNode; var label = getNodeDisplayLabel(node); var html = ''; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; document.getElementById('plc-modal-ok').onclick = function() { closeModal(); var delType = node.type; editLinks = editLinks.filter(function(l) { return l.srcIdx !== idx && l.dstIdx !== idx; }); for (var i = 0; i < editLinks.length; i++) { if (editLinks[i].srcIdx > idx) editLinks[i].srcIdx--; if (editLinks[i].dstIdx > idx) editLinks[i].dstIdx--; } editNodes.splice(idx, 1); editSelectedNode = -1; // 压缩同类型门序号:删除 #1 后 #2→#1, #3→#2 … if (delType !== NT_INPUT && delType !== NT_OUTPUT) { var sameType = []; for (var s = 0; s < editNodes.length; s++) { if (editNodes[s].type === delType) sameType.push(s); } sameType.sort(function(a, b) { return (editNodes[a].id & 0xFF) - (editNodes[b].id & 0xFF); }); for (var a = 0; a < sameType.length; a++) { editNodes[sameType[a]].id = (editNodes[sameType[a]].id & ~0xFF) | a; } editNextGateIdx[delType] = sameType.length; } drawEditor(); showToast('模块已删除', 'ok'); }; } function modifySelectedNode() { if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) { showToast('请先在画布上双击或单击选中一个模块', 'err'); return; } var idx = editSelectedNode; var node = editNodes[idx]; if (node.type === NT_INPUT || node.type === NT_OUTPUT) { showRebindSignalModal(idx); } else if (node.type === NT_AND || node.type === NT_OR || node.type === NT_NOT) { showGateConfigModal(idx); } else if (node.type === NT_62P || node.type === NT_62D) { showDelayEditModal(idx); } else if (node.type === NT_SR || node.type === NT_RS) { showRSInfoModal(idx); } else { showGateConfigModal(idx); } } // 节点双击 — 根据类型弹出编辑框 function onNodeDoubleClicked(idx) { var node = editNodes[idx]; editSelectedNode = idx; drawEditor(); if (node.type === NT_INPUT || node.type === NT_OUTPUT) { showRebindSignalModal(idx); } else if (node.type === NT_62P || node.type === NT_62D) { showDelayEditModal(idx); } else if (node.type === NT_SR || node.type === NT_RS) { showRSInfoModal(idx); } else { showGateConfigModal(idx); } } /** 延时模块双击:弹出延时编辑框 */ function showDelayEditModal(nodeIdx) { var node = editNodes[nodeIdx]; var info = NODE_INFO[node.type] || {}; var curMs = node.delayMs || 1000; var html = ''; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; document.getElementById('plc-modal-ok').onclick = function() { var val = parseInt(document.getElementById('plc-delay-input').value); if (isNaN(val) || val < 0) { showToast('请输入有效的延时毫秒数', 'err'); return; } editNodes[nodeIdx].delayMs = val; closeModal(); drawEditor(); showToast('延时已更新: ' + val + ' ms', 'ok'); }; document.getElementById('plc-delay-input').onkeydown = function(e) { if (e.key === 'Enter') document.getElementById('plc-modal-ok').click(); }; setTimeout(function() { var inp = document.getElementById('plc-delay-input'); if (inp) { inp.focus(); inp.select(); } }, 100); } /** SR/RS 双击:显示 R/S 连接信息 */ function showRSInfoModal(nodeIdx) { var node = editNodes[nodeIdx]; var info = NODE_INFO[node.type] || {}; var sSrc = '', rSrc = ''; for (var li = 0; li < editLinks.length; li++) { var l = editLinks[li]; if (l.dstIdx === nodeIdx) { var srcNode = editNodes[l.srcIdx]; var desc = getEditorNodeLabel(srcNode).replace(/\n/g, ' '); if (node.type === NT_SR) { if (l.dstPin === 0) sSrc = desc; else rSrc = desc; } else { if (l.dstPin === 1) sSrc = desc; else rSrc = desc; } } } var html = ''; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; } // 重新绑定 I/O 节点信号 function showRebindSignalModal(nodeIdx) { var node = editNodes[nodeIdx]; var signals = dcOutSignals; if (signals.length === 0) { showToast('信号列表为空,请先加载', 'err'); return; } var title = (node.type === NT_INPUT) ? '📥 重新绑定输入信号' : '📤 重新绑定输出信号'; var html = ''; showModalCustom(html, function(no, saddr, desc) { var sig = findSignalByNo(no); editNodes[nodeIdx].no = no; editNodes[nodeIdx].id = no; editNodes[nodeIdx].saddr = saddr; editNodes[nodeIdx].desc = desc; drawEditor(); showToast('已绑定: ' + saddr, 'ok'); return true; }); } // 配置门电路引脚数 function showGateConfigModal(nodeIdx) { var node = editNodes[nodeIdx]; var curPins = getNodeInPins(node); var info = NODE_INFO[node.type] || {}; var maxPins = info.inMax || 99; var minPins = 1; if (node.type === NT_AND || node.type === NT_OR) minPins = 1; else if (node.type === NT_NOT) minPins = 1; else minPins = 1; var html = ''; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; document.getElementById('plc-modal-save').onclick = function() { var v = parseInt(document.getElementById('plc-gate-pins').value); if (isNaN(v) || v < minPins) { showToast('最少 ' + minPins + ' 个输入引脚', 'err'); return; } if (v > maxPins) { showToast('最多 ' + maxPins + ' 个输入引脚', 'err'); return; } editNodes[nodeIdx].inPins = v; closeModal(); drawEditor(); showToast(info.name + ' 输入引脚已设为 ' + v, 'ok'); }; } /* ---- 编辑器 Canvas ---- */ function setupEditorCanvas() { var wrap = document.getElementById('plc-edit-canvas-wrap'); var canvas = document.getElementById('plc-edit-canvas'); if (!wrap || !canvas) return; function resize() { canvas.width = wrap.clientWidth; canvas.height = Math.max(500, wrap.clientHeight - 4); // 居中画布 editScale = 0.7; editPanX = canvas.width / 2; editPanY = canvas.height / 2; drawEditor(); } setTimeout(resize, 50); window.addEventListener('resize', function() { resize(); }); canvas.onmousedown = function(e) { var rect = canvas.getBoundingClientRect(); var mx = (e.clientX - rect.left - editPanX) / editScale; var my = (e.clientY - rect.top - editPanY) / editScale; // 检查是否点击了引脚 → 连线操作 var pinHit = hitTestPin(mx, my); if (pinHit) { if (editWiring) { // 完成连线 if (editWiring.fromIdx !== pinHit.nodeIdx) { if (editWiring.fromSide === 'out' && pinHit.side === 'in') { var srcInfo = NODE_INFO[editNodes[editWiring.fromIdx].type] || {}; var outMax = srcInfo.outMax || 99; var outCount = 0; for (var lc = 0; lc < editLinks.length; lc++) { if (editLinks[lc].srcIdx === editWiring.fromIdx) outCount++; } if (outCount >= outMax) { showToast('该模块输出已达上限 (' + outMax + ')', 'err'); editWiring = null; editSelectedNode = -1; drawEditor(); return; } var link = { srcIdx: editWiring.fromIdx, dstIdx: pinHit.nodeIdx, dstPin: pinHit.pinIdx }; // SR/RS 每个引脚只能接一根线 var dstNode = editNodes[pinHit.nodeIdx]; if (dstNode.type === NT_SR || dstNode.type === NT_RS) { for (var lc2 = 0; lc2 < editLinks.length; lc2++) { if (editLinks[lc2].dstIdx === pinHit.nodeIdx && editLinks[lc2].dstPin === pinHit.pinIdx) { var pinLabel = (dstNode.type === NT_SR) ? ['S','R'] : ['R','S']; showToast('引脚 ' + pinLabel[pinHit.pinIdx] + ' 已有连线,每个引脚只能接一根线', 'err'); editWiring = null; editSelectedNode = -1; drawEditor(); return; } } } editLinks.push(link); showToast('连线已添加', 'ok'); } else if (editWiring.fromSide === 'in' && pinHit.side === 'out') { editLinks.push({ srcIdx: pinHit.nodeIdx, dstIdx: editWiring.fromIdx, dstPin: 0 }); showToast('连线已添加', 'ok'); } else { showToast('不能这样连线', 'err'); } } editWiring = null; editSelectedNode = -1; editSelectedLink = -1; drawEditor(); return; } else { editWiring = { fromIdx: pinHit.nodeIdx, fromSide: pinHit.side, fromPin: pinHit.pinIdx }; editSelectedNode = -1; editSelectedLink = -1; drawEditor(); return; } } // 检查是否点击了连线 → 选中高亮(左键选择,右键删除) var linkHit = hitTestLink(mx, my); if (linkHit >= 0) { editSelectedLink = linkHit; editSelectedNode = -1; editDragNode = null; if (editWiring) { editWiring = null; } drawEditor(); return; } // 检查是否点击了节点 → 拖拽或选中 var nodeHit = hitTestNode(mx, my); if (nodeHit >= 0) { editSelectedNode = nodeHit; editSelectedLink = -1; editDragNode = nodeHit; editDragOffX = mx - editNodes[nodeHit]._lx; editDragOffY = my - editNodes[nodeHit]._ly; drawEditor(); return; } // 空白处取消选中和连线,并开始画布拖拽 editSelectedNode = -1; editSelectedLink = -1; if (editWiring) { editWiring = null; } editDragCanvas = { startX: e.clientX, startY: e.clientY, panX: editPanX, panY: editPanY }; canvas.style.cursor = 'grabbing'; drawEditor(); }; canvas.onmousemove = function(e) { var rect = canvas.getBoundingClientRect(); var mx = (e.clientX - rect.left - editPanX) / editScale; var my = (e.clientY - rect.top - editPanY) / editScale; if (editDragCanvas) { editPanX = editDragCanvas.panX + (e.clientX - editDragCanvas.startX); editPanY = editDragCanvas.panY + (e.clientY - editDragCanvas.startY); drawEditor(); return; } if (editDragNode !== null) { editNodes[editDragNode]._lx = mx - editDragOffX; editNodes[editDragNode]._ly = my - editDragOffY; drawEditor(); return; } if (editWiring && editWiring.fromIdx < editNodes.length) { drawEditor(); } var pin = hitTestPin(mx, my); canvas.style.cursor = pin ? 'crosshair' : (hitTestNode(mx, my) >= 0 ? 'grab' : 'default'); }; canvas.onmouseup = function(e) { if (editDragCanvas) { editDragCanvas = null; canvas.style.cursor = 'default'; return; } if (editDragNode !== null) { var node = editNodes[editDragNode]; var nodeIdx = editDragNode; var dx = Math.abs((e.clientX - canvas.getBoundingClientRect().left - editPanX) / editScale - node._lx - editDragOffX); var dy = Math.abs((e.clientY - canvas.getBoundingClientRect().top - editPanY) / editScale - node._ly - editDragOffY); // 小于 3px 视为点击(非拖拽) if (dx < 3 && dy < 3) { // 双击检测:同节点 400ms 内连续点击 var now = Date.now(); if (nodeIdx === editLastClickNode && (now - editLastClickTime) < 400) { onNodeDoubleClicked(nodeIdx); editLastClickTime = 0; editLastClickNode = -1; } else { editLastClickTime = now; editLastClickNode = nodeIdx; } } } editDragNode = null; }; canvas.onmouseleave = function() { editDragNode = null; editDragCanvas = null; canvas.style.cursor = 'default'; }; canvas.onwheel = function(e) { e.preventDefault(); var delta = e.deltaY > 0 ? 0.9 : 1.1; var rect = canvas.getBoundingClientRect(); var mx = e.clientX - rect.left; var my = e.clientY - rect.top; var ns = Math.max(0.2, Math.min(3, editScale * delta)); editPanX = mx - (mx - editPanX) * (ns / editScale); editPanY = my - (my - editPanY) * (ns / editScale); editScale = ns; drawEditor(); }; // 右键 —— 删除选中的连线 / 节点 canvas.oncontextmenu = function(e) { e.preventDefault(); if (editSelectedLink >= 0) { editLinks.splice(editSelectedLink, 1); editSelectedLink = -1; editSelectedNode = -1; showToast('连线已删除', 'ok'); drawEditor(); return; } // 如果选中了节点也可以用右键删除 if (editSelectedNode >= 0) { deleteSelectedNode(); return; } }; canvas.tabIndex = 0; canvas.onkeydown = function(e) { if (e.key === 'Escape') { editWiring = null; editSelectedNode = -1; editSelectedLink = -1; drawEditor(); } if (e.key === 'Delete' && editSelectedNode >= 0) { deleteSelectedNode(); } if (e.key === 'Delete' && editSelectedLink >= 0) { editLinks.splice(editSelectedLink, 1); editSelectedLink = -1; showToast('连线已删除', 'ok'); drawEditor(); } }; } /* ---- 获取节点的实际 inPins(优先使用节点自身的配置) ---- */ function getNodeInPins(node) { if (node.inPins !== undefined && node.inPins > 0) return node.inPins; var info = NODE_INFO[node.type] || {}; return info.inPins || 0; } function getNodeSize(node) { var isIO = (node.type === NT_OUTPUT || node.type === NT_INPUT); var isSRRS = (node.type === NT_SR || node.type === NT_RS); var inP = getNodeInPins(node); var h = isIO ? 80 : (isSRRS ? 86 : (66 + Math.max(0, inP - 2) * 14)); var w = isIO ? 250 : 170; return { w: w, h: h }; } function hitTestNode(mx, my) { for (var i = editNodes.length - 1; i >= 0; i--) { var n = editNodes[i]; var sz = getNodeSize(n); var x = n._lx, y = n._ly; if (mx > x - sz.w/2 && mx < x + sz.w/2 && my > y - sz.h/2 && my < y + sz.h/2) return i; } return -1; } function hitTestPin(mx, my) { var pinR = 8; for (var i = editNodes.length - 1; i >= 0; i--) { var n = editNodes[i]; var sz = getNodeSize(n); var info = NODE_INFO[n.type] || NODE_INFO[0]; var x = n._lx, y = n._ly; var isIO = (n.type === NT_INPUT || n.type === NT_OUTPUT); var isSRRS = (n.type === NT_SR || n.type === NT_RS); var inP = getNodeInPins(n); // 输入引脚(左侧) if (info.inPins > 0) { var testPins = isSRRS ? 2 : (isIO ? inP : 1); for (var p = 0; p < testPins; p++) { var py = y - sz.h/2 + sz.h * (p + 1) / (isSRRS ? 3 : (inP + 1)); var dx = mx - (x - sz.w/2), dy = my - py; if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'in', pinIdx: p }; } } // 输出引脚(右侧) if (info.outPins > 0) { var dx = mx - (x + sz.w/2), dy = my - y; if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'out', pinIdx: 0 }; } } return null; } function hitTestLink(mx, my) { for (var i = 0; i < editLinks.length; i++) { var l = editLinks[i]; if (l.srcIdx >= editNodes.length || l.dstIdx >= editNodes.length) continue; var sn = editNodes[l.srcIdx], dn = editNodes[l.dstIdx]; var szSrc = getNodeSize(sn), szDst = getNodeSize(dn); var x1 = sn._lx + szSrc.w/2, y1 = sn._ly; var dstIsSRRS = (dn.type === NT_SR || dn.type === NT_RS); var gateInPins = getNodeInPins(dn); var dstPinIdx2 = (l.dstPin !== undefined) ? l.dstPin : 0; var y2 = dn._ly - szDst.h/2 + szDst.h * (dstPinIdx2 + 1) / (dstIsSRRS ? 3 : (gateInPins + 1)); var x2 = dn._lx - szDst.w/2; var t = ((mx - x1)*(x2 - x1) + (my - y1)*(y2 - y1)) / ((x2 - x1)*(x2 - x1) + (y2 - y1)*(y2 - y1)); t = Math.max(0, Math.min(1, t)); var px = x1 + t * (x2 - x1), py = y1 + t * (y2 - y1); if (Math.sqrt((mx - px)*(mx - px) + (my - py)*(my - py)) < 10) return i; } return -1; } function getNodeLabel(n) { var info = NODE_INFO[n.type] || {}; var label = (info.name || '?'); if (n.saddr) label += ' ' + n.saddr; else if (n.type <= 1) label += ' #' + n.id; else label += ' #' + (n.id & 0xFF); return label; } /* ---- 编辑器绘制 ---- */ function drawEditor() { var canvas = document.getElementById('plc-edit-canvas'); if (!canvas) return; var ctx = canvas.getContext('2d'); var W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.translate(editPanX, editPanY); ctx.scale(editScale, editScale); // 网格 ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; var gridSize = 40; var startX = Math.floor(-editPanX / editScale / gridSize) * gridSize; var startY = Math.floor(-editPanY / editScale / gridSize) * gridSize; for (var gx = startX - gridSize; gx < startX + W/editScale + gridSize; gx += gridSize) { ctx.beginPath(); ctx.moveTo(gx, startY - gridSize); ctx.lineTo(gx, startY + H/editScale + gridSize); ctx.stroke(); } for (var gy = startY - gridSize; gy < startY + H/editScale + gridSize; gy += gridSize) { ctx.beginPath(); ctx.moveTo(startX - gridSize, gy); ctx.lineTo(startX + W/editScale + gridSize, gy); ctx.stroke(); } // 区域标签 ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.font = '28px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('📥 输入信号区', -250, -200); ctx.fillText('🧮 逻辑运算区', 0, -200); ctx.fillText('📤 输出信号区', 250, -200); // 共用绘制 drawGraphContent(ctx, editNodes, editLinks, editSelectedNode, editSelectedLink, editWiring); ctx.restore(); // 更新信息栏 var infoEl = document.getElementById('plc-edit-info'); if (infoEl) { var selInfo = ''; if (editSelectedNode >= 0 && editSelectedNode < editNodes.length) { selInfo = ' | 🔍 选中: ' + getNodeDisplayLabel(editNodes[editSelectedNode]); } infoEl.innerHTML = '节点: ' + editNodes.length + ' | 连线: ' + editLinks.length + ' | Key: ' + escHtml(editKey) + ' | 名称: ' + escHtml(editName || '(未设置)') + (editWiring ? ' | 🔗 连线中... (点击目标引脚完成,ESC取消)' : '') + selInfo + ' | 右键删除连线 | 滚轮缩放 | Delete键删除选中'; } } /* ---- 添加输入/输出/运算弹窗 ---- */ function showAddInputModal() { if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; } var signals = dcOutSignals; var html = ''; showModalCustom(html, function(no, saddr, desc) { editNodes.push({ type: NT_INPUT, id: no, x: 0, y: 0, _lx: -260, _ly: editNodes.filter(function(n){return n.type===NT_INPUT;}).length * 110, saddr: saddr, desc: desc, no: no }); drawEditor(); showToast('已添加输入: ' + saddr, 'ok'); return true; }); } function showAddOutputModal() { if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; } var signals = dcOutSignals; var html = ''; showModalCustom(html, function(no, saddr, desc) { editNodes.push({ type: NT_OUTPUT, id: no, x: 0, y: 0, _lx: 260, _ly: editNodes.filter(function(n){return n.type===NT_OUTPUT;}).length * 110, saddr: saddr, desc: desc, no: no }); drawEditor(); showToast('已添加输出: ' + saddr, 'ok'); return true; }); } function showAddGateModal() { var gates = [ { type: NT_AND, name: '与门 (AND)', desc: '2输入1输出,所有输入为1时输出1', icon: '🔷' }, { type: NT_OR, name: '或门 (OR)', desc: '2输入1输出,任一输入为1时输出1', icon: '🔶' }, { type: NT_NOT, name: '非门 (NOT)', desc: '1输入1输出,输入取反', icon: '🔺' }, { type: NT_62P, name: '62p 延时', desc: '输入1延时输出1,输入0立即输出0', icon: '⏱️' }, { type: NT_62D, name: '62d 延时', desc: '输入1立即输出1,输入0延时输出0', icon: '⏱️' }, { type: NT_RISING, name: '上升沿', desc: '0→1跳变时输出脉冲', icon: '⬆️' }, { type: NT_FALLING, name: '下降沿', desc: '1→0跳变时输出脉冲', icon: '⬇️' }, { type: NT_SR, name: 'SR触发器', desc: 'S置位优先,S=1置位 R=1复位', icon: '🔒' }, { type: NT_RS, name: 'RS触发器', desc: 'R复位优先,R=1复位 S=1置位', icon: '🔒' } ]; var html = ''; // 自定义 modal 处理 var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; var rows = document.querySelectorAll('#plc-modal .plc-modal-row'); for (var j = 0; j < rows.length; j++) { rows[j].onclick = function(e) { e.stopPropagation(); var gateType = parseInt(this.getAttribute('data-gate-type')); var info = NODE_INFO[gateType]; var nextIdx = editNextGateIdx[gateType] || 0; // 延时模块:弹出自定义延时输入框 if (gateType === NT_62P || gateType === NT_62D) { closeModal(); showDelayInputModal(gateType, nextIdx, info); return; } // 非延时模块:直接添加 addGateNode(gateType, nextIdx, 0, info); editNextGateIdx[gateType] = nextIdx + 1; closeModal(); drawEditor(); }; } } /** 弹出延时毫秒输入框 */ function showDelayInputModal(gateType, nextIdx, info) { var html = ''; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; document.getElementById('plc-modal-ok').onclick = function() { var val = parseInt(document.getElementById('plc-delay-input').value); if (isNaN(val) || val < 0) { showToast('请输入有效的延时毫秒数', 'err'); return; } closeModal(); addGateNode(gateType, nextIdx, val, info); editNextGateIdx[gateType] = nextIdx + 1; drawEditor(); }; // 回车确认 document.getElementById('plc-delay-input').onkeydown = function(e) { if (e.key === 'Enter') document.getElementById('plc-modal-ok').click(); }; setTimeout(function() { var inp = document.getElementById('plc-delay-input'); if (inp) { inp.focus(); inp.select(); } }, 100); } /** 添加门节点到编辑区,自动找空白位置 */ function addGateNode(gateType, nextIdx, delayMs, info) { var pos = findEmptyPosition(); var node = { type: gateType, id: nextIdx, x: 0, y: 0, _lx: pos.x, _ly: pos.y, saddr: '', desc: '', no: 0 }; if (delayMs > 0) node.delayMs = delayMs; editNodes.push(node); showToast('已添加: ' + (info ? info.name : NODE_INFO[gateType].name) + ' #' + nextIdx, 'ok'); } /** 寻找画布上最空白的区域 */ function findEmptyPosition() { if (editNodes.length === 0) return { x: 0, y: 0 }; // 先计算已有节点的包围盒,在右侧偏下寻找空白 var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (var i = 0; i < editNodes.length; i++) { var n = editNodes[i]; if (n._lx < minX) minX = n._lx; if (n._lx > maxX) maxX = n._lx; if (n._ly < minY) minY = n._ly; if (n._ly > maxY) maxY = n._ly; } // 在所有节点下方居中放置 var candidates = [ { x: 0, y: maxY + 140 }, { x: 0, y: minY - 140 }, { x: maxX + 300, y: (minY + maxY) / 2 }, { x: minX - 300, y: (minY + maxY) / 2 } ]; for (var c = 0; c < candidates.length; c++) { if (!isPositionOccupied(candidates[c].x, candidates[c].y)) { return candidates[c]; } } return { x: 0, y: maxY + 140 }; } function isPositionOccupied(x, y) { for (var i = 0; i < editNodes.length; i++) { var n = editNodes[i]; if (Math.abs(n._lx - x) < 120 && Math.abs(n._ly - y) < 80) return true; } return false; } function showModal(html, onSelect) { var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; // 搜索 var searchInput = document.getElementById('plc-modal-search'); if (searchInput) { searchInput.oninput = function() { var q = this.value.toLowerCase(); var rows = document.querySelectorAll('#plc-modal .plc-modal-row'); for (var r = 0; r < rows.length; r++) { var text = (rows[r].textContent || '').toLowerCase(); rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none'; } }; } var rows = document.querySelectorAll('#plc-modal .plc-modal-row'); for (var j = 0; j < rows.length; j++) { rows[j].onclick = function() { if (this.classList.contains('plc-modal-used')) { showToast('该信号已被使用', 'err'); return; } var noAttr = this.getAttribute('data-no'); var saddr = this.getAttribute('data-saddr'); var desc = this.getAttribute('data-desc') || ''; var no = parseInt(noAttr) || 0; var result = onSelect(no, saddr, desc); if (result !== false) closeModal(); }; } } function showModalCustom(html, onSelect) { var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; var searchInput = document.getElementById('plc-modal-search'); if (searchInput) { searchInput.oninput = function() { var q = this.value.toLowerCase(); var rows = document.querySelectorAll('#plc-modal .plc-modal-row'); for (var r = 0; r < rows.length; r++) { var text = (rows[r].textContent || '').toLowerCase(); rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none'; } }; } var rows = document.querySelectorAll('#plc-modal .plc-modal-row'); for (var j = 0; j < rows.length; j++) { rows[j].onclick = function() { if (this.classList.contains('plc-modal-used')) { showToast('该信号已被使用', 'err'); return; } var noAttr = this.getAttribute('data-no'); var saddr = this.getAttribute('data-saddr'); var desc = this.getAttribute('data-desc') || ''; var no = parseInt(noAttr) || 0; var result = onSelect(no, saddr, desc); if (result !== false) closeModal(); }; } } function closeModal() { var m = document.getElementById('plc-modal'); if (m) m.remove(); } /* ---- 生成并保存配置 ---- */ // 生成 XML 配置文件 function generateXmlConfig() { var typeNames = {2:'OR',3:'AND',4:'NOT',5:'T62P',6:'T62D',7:'RISING',8:'SR',9:'RS',10:'FALLING'}; var adjIn = {}, adjOut = {}; for (var i = 0; i < editNodes.length; i++) { adjIn[i] = []; adjOut[i] = []; } for (var j = 0; j < editLinks.length; j++) { var l = editLinks[j]; adjOut[l.srcIdx].push(l.dstIdx); adjIn[l.dstIdx].push(l.srcIdx); } var visited = {}; var chains = []; for (var s = 0; s < editNodes.length; s++) { if (visited[s]) continue; var c = [], q = [s]; visited[s] = true; while (q.length > 0) { var cur = q.shift(); c.push(cur); var nbrs = adjOut[cur].concat(adjIn[cur]); for (var nb = 0; nb < nbrs.length; nb++) if (!visited[nbrs[nb]]) { visited[nbrs[nb]] = true; q.push(nbrs[nb]); } } chains.push(c); } function sigXml(n) { return ''; } function gateTag(n, nodeIdx) { var tn = typeNames[n.type] || '?'; var a = ' type="' + tn + '" id="' + (n.id & 0xFF) + '" x="' + Math.round(n._lx) + '" y="' + Math.round(n._ly) + '"'; if (n.inPins && n.inPins > 2) a += ' inPins="' + n.inPins + '"'; if (n.type === NT_62P || n.type === NT_62D) a += ' delayMs="' + (n.delayMs || 1000) + '"'; // SR/RS: 输出 R/S 属性 if ((n.type === NT_SR || n.type === NT_RS) && nodeIdx !== undefined) { var srLinks = []; for (var li = 0; li < editLinks.length; li++) { if (editLinks[li].dstIdx === nodeIdx) srLinks.push(editLinks[li]); } // 按 dstPin 排序,未设置的用出现顺序兜底 var hasPin = false; for (var sl = 0; sl < srLinks.length; sl++) { if (srLinks[sl].dstPin !== undefined) { hasPin = true; break; } } if (!hasPin) { // 兜底:SR 按出现顺序 S→R,RS 按出现顺序 R→S for (var sl2 = 0; sl2 < srLinks.length; sl2++) { if (n.type === NT_SR) srLinks[sl2].dstPin = sl2; // 0=S, 1=R else srLinks[sl2].dstPin = 1 - sl2; // 0=R, 1=S } } for (var sl3 = 0; sl3 < srLinks.length; sl3++) { var l3 = srLinks[sl3]; var srcNode = editNodes[l3.srcIdx]; var srcDesc = ''; if (srcNode.type === NT_INPUT) { srcDesc = 'Input#' + srcNode.id; } else { srcDesc = 'Comb#' + (srcNode.combId !== undefined ? srcNode.combId : (srcNode.id & 0xFF)); } if (n.type === NT_SR) { if (l3.dstPin === 0) a += ' S="' + srcDesc + '"'; else a += ' R="' + srcDesc + '"'; } else { if (l3.dstPin === 1) a += ' S="' + srcDesc + '"'; else a += ' R="' + srcDesc + '"'; } } } return ''; } /** 后序遍历分配 combId(自底向上,从 0 起) */ function assignCombIds(idx, ctrObj, cGatesArr) { editNodes[idx].combId = undefined; for (var ui = 0; ui < adjIn[idx].length; ui++) { var up = adjIn[idx][ui]; if (editNodes[up].type > 1 && cGatesArr.indexOf(up) >= 0) { assignCombIds(up, ctrObj, cGatesArr); } } editNodes[idx].combId = ctrObj.count++; } function combXml(idx, indent) { var s = ''; var cid = editNodes[idx].combId; s += indent + '\n'; var innerIndent = indent + ' '; var di = []; for (var fi = 0; fi < adjIn[idx].length; fi++) if (editNodes[adjIn[idx][fi]].type === NT_INPUT) di.push(adjIn[idx][fi]); if (di.length > 0) { s += innerIndent + '\n'; for (var d = 0; d < di.length; d++) s += innerIndent + ' ' + sigXml(editNodes[di[d]]) + '\n'; s += innerIndent + '\n'; } for (var ui = 0; ui < adjIn[idx].length; ui++) if (editNodes[adjIn[idx][ui]].type > 1 && cGates.indexOf(adjIn[idx][ui]) >= 0) { s += combXml(adjIn[idx][ui], innerIndent); } s += innerIndent + gateTag(editNodes[idx], idx) + '\n'; var dos = []; for (var oi = 0; oi < adjOut[idx].length; oi++) if (editNodes[adjOut[idx][oi]].type === NT_OUTPUT) dos.push(adjOut[idx][oi]); if (dos.length > 0) { s += innerIndent + '\n'; for (var o = 0; o < dos.length; o++) s += innerIndent + ' ' + sigXml(editNodes[dos[o]]) + '\n'; s += innerIndent + '\n'; } s += indent + '\n'; return s; } var xml = '\n'; xml += '\n'; for (var ci = 0; ci < chains.length; ci++) { var chain = chains[ci]; var cIns = [], cGates = [], cOuts = []; for (var ni = 0; ni < chain.length; ni++) { var nd = editNodes[chain[ni]]; if (nd.type === NT_INPUT) cIns.push(chain[ni]); else if (nd.type === NT_OUTPUT) cOuts.push(chain[ni]); else cGates.push(chain[ni]); } if (cGates.length === 0) continue; xml += ' \n'; if (cGates.length === 1) { // 简单链: Inputs → Gate → Outputs xml += ' \n'; for (var ii = 0; ii < cIns.length; ii++) xml += ' ' + sigXml(editNodes[cIns[ii]]) + '\n'; xml += ' \n'; xml += ' ' + gateTag(editNodes[cGates[0]], cGates[0]) + '\n'; xml += ' \n'; for (var oo = 0; oo < cOuts.length; oo++) xml += ' ' + sigXml(editNodes[cOuts[oo]]) + '\n'; xml += ' \n'; } else { // 组合链: 链级门 + Comb + Outputs // 链级门 = 下游没有 cGates 内其他门的门(只连输出或链外) var chainGates = []; for (var gg = 0; gg < cGates.length; gg++) { var allToOut = true; for (var so = 0; so < adjOut[cGates[gg]].length; so++) if (cGates.indexOf(adjOut[cGates[gg]][so]) >= 0) { allToOut = false; break; } if (allToOut) chainGates.push(cGates[gg]); } // combRoots: 直接馈入 chainGate 的门(combXml 会递归处理上游嵌套) var combRootSet = {}; for (var cg = 0; cg < chainGates.length; cg++) { for (var ui = 0; ui < adjIn[chainGates[cg]].length; ui++) { var up = adjIn[chainGates[cg]][ui]; if (cGates.indexOf(up) >= 0) combRootSet[up] = true; } } // 去重:若 A 是 B 的下游,只保留 B(combXml 从 B 开始会递归包含 A) var combRootArr = Object.keys(combRootSet).map(Number); var combRoots = []; for (var rr = 0; rr < combRootArr.length; rr++) { var isDownstream = false; for (var rr2 = 0; rr2 < combRootArr.length; rr2++) { if (rr === rr2) continue; var visited2 = {}; var queue2 = [combRootArr[rr2]]; visited2[combRootArr[rr2]] = true; while (queue2.length > 0) { var cur2 = queue2.shift(); if (cur2 === combRootArr[rr]) { isDownstream = true; break; } for (var si = 0; si < adjOut[cur2].length; si++) { var nx = adjOut[cur2][si]; if (!visited2[nx] && cGates.indexOf(nx) >= 0) { visited2[nx] = true; queue2.push(nx); } } } if (isDownstream) break; } if (!isDownstream) combRoots.push(combRootArr[rr]); } // 输出 Comb(每个 combRoot 对应一个顶层 Comb,内部递归嵌套) // 先分配 combId var combIdCtr = { count: 0 }; for (var tt = 0; tt < combRoots.length; tt++) { assignCombIds(combRoots[tt], combIdCtr, cGates); } for (var tt = 0; tt < combRoots.length; tt++) { xml += combXml(combRoots[tt], ' '); } // 输出链级门 — 若存在直接 Input 连接(未进入 Comb 的),先行输出 for (var cg = 0; cg < chainGates.length; cg++) { var cgNode = editNodes[chainGates[cg]]; var directInputs = []; for (var cui = 0; cui < adjIn[chainGates[cg]].length; cui++) { var upIdx = adjIn[chainGates[cg]][cui]; if (editNodes[upIdx].type === NT_INPUT) { directInputs.push(upIdx); } } if (directInputs.length > 0) { xml += ' \n'; for (var di2 = 0; di2 < directInputs.length; di2++) { xml += ' ' + sigXml(editNodes[directInputs[di2]]) + '\n'; } xml += ' \n'; } xml += ' ' + gateTag(cgNode, chainGates[cg]) + '\n'; } // 链级 Outputs — 仅包含直接连到 chainGate 的输出信号 // (Comb 内部的输出信号已在 combXml 中处理) var chainOutSet = {}; for (var cg2 = 0; cg2 < chainGates.length; cg2++) { for (var oi = 0; oi < adjOut[chainGates[cg2]].length; oi++) { var outIdx = adjOut[chainGates[cg2]][oi]; if (editNodes[outIdx].type === NT_OUTPUT) chainOutSet[outIdx] = true; } } var chainOuts = Object.keys(chainOutSet).map(Number); // 同时保留直接连到输入信号(无门)的输出 for (var oo2 = 0; oo2 < cOuts.length; oo2++) { var hasGateSrc = false; for (var ai = 0; ai < adjIn[cOuts[oo2]].length; ai++) { if (cGates.indexOf(adjIn[cOuts[oo2]][ai]) >= 0) hasGateSrc = true; } if (!hasGateSrc) chainOutSet[cOuts[oo2]] = true; } chainOuts = Object.keys(chainOutSet).map(Number); if (chainOuts.length > 0) { xml += ' \n'; for (var oo = 0; oo < chainOuts.length; oo++) xml += ' ' + sigXml(editNodes[chainOuts[oo]]) + '\n'; xml += ' \n'; } } xml += ' \n'; } xml += '\n'; return xml; } function escXml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function generateAndSaveConfig() { if (editNodes.length === 0) { showToast('请先添加节点', 'err'); return; } var hasInput = editNodes.some(function(n) { return n.type === NT_INPUT; }); var hasOutput = editNodes.some(function(n) { return n.type === NT_OUTPUT; }); if (!hasInput || !hasOutput) { showToast('至少需要一个输入和一个输出', 'err'); return; } if (editLinks.length === 0) { showToast('请先连线', 'err'); return; } var seenIds = {}; for (var di = 0; di < editNodes.length; di++) { var dn = editNodes[di]; if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue; var key2 = dn.type + ':' + (dn.id & 0xFF); if (seenIds[key2]) { var next = editNextGateIdx[dn.type] || 0; editNodes[di].id = (dn.id & ~0xFF) | next; editNextGateIdx[dn.type] = next + 1; } else { seenIds[key2] = true; } } var fileContent = generateXmlConfig(); var preview = fileContent.length > 2000 ? fileContent.substring(0,2000)+'…' : fileContent; var previewHtml = ''; var div = document.createElement('div'); div.innerHTML = previewHtml; document.body.appendChild(div.firstElementChild); document.getElementById('plc-modal-cancel').onclick = closeModal; document.getElementById('plc-modal-save').onclick = function() { closeModal(); var oldContent = loadedFiles['config/PLC/Reclose_logic.xml'] || ''; if (oldContent && oldContent.length > 10) { WsClient.send({ type: 'set_file', path: 'config/PLC/Reclose_logic.xml.bak', content: oldContent }); } var sent = WsClient.send({ type: 'set_file', path: 'config/PLC/Reclose_logic.xml', content: fileContent }); if (sent) { showToast('XML 配置已保存!请重启 PLC 模块生效', 'ok'); loadedFiles['config/PLC/Reclose_logic.xml'] = null; loadedFiles['config/PLC/Reclose_logic.xml.bak'] = null; logicGraphs = []; } else { showToast('保存失败:WebSocket 未连接', 'err'); } }; } /* ========== 页面生命周期 ========== */ function onEnter() { // 重试之前因WS未连接而失败的文件请求 if (PlcConfigPage._retryList && PlcConfigPage._retryList.length > 0) { var retry = PlcConfigPage._retryList.slice(); PlcConfigPage._retryList = []; for (var ri = 0; ri < retry.length; ri++) { requestFile(retry[ri], PlcConfigPage._pending[retry[ri]] || function(){}); } } // 自动加载 datacenter out 信号 if (dcOutSignals.length === 0) { loadDcOutSignals(); } } function onLeave() { editWiring = null; editDragNode = null; editSelectedNode = -1; editSelectedLink = -1; } function onData(data) { if (!data || typeof data !== 'object') return; // 处理 file_content 响应 if (data.type === 'file_content') { var path = data.path; var content = data.content || ''; var error = data.error || ''; if (error) { // 文件不存在等情况:仍然触发回调(传null),确保 loadLogicFileAll 的 tryCb 能正常计数 loadedFiles[path] = null; if (PlcConfigPage._pending && PlcConfigPage._pending[path]) { PlcConfigPage._pending[path](null); delete PlcConfigPage._pending[path]; } } else { loadedFiles[path] = content; if (PlcConfigPage._pending && PlcConfigPage._pending[path]) { PlcConfigPage._pending[path](content); delete PlcConfigPage._pending[path]; } } if (PlcConfigPage._retryList) { var rIdx = PlcConfigPage._retryList.indexOf(path); if (rIdx >= 0) PlcConfigPage._retryList.splice(rIdx, 1); } return; } // 处理 file_saved 响应 if (data.type === 'file_saved') { if (data.success) { showToast('文件已保存: ' + data.path, 'ok'); } else { showToast('保存失败: ' + (data.error || '未知错误'), 'err'); } return; } } return { render: render, onEnter: onEnter, onLeave: onLeave, onData: onData, _pending: {} }; })(); Router.register('#plc_config', function(c) { PlcConfigPage.render(c); });