RTU_ALL_AI/config/web/js/pages/plc_config.js

2529 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* ---- 通过节点 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 = '<div class="plc-cfg-tabs">';
for (var i = 0; i < tabs.length; i++) {
var cls = (tabs[i].id === activeTab) ? 'plc-cfg-tab active' : 'plc-cfg-tab';
html += '<button class="' + cls + '" data-tab="' + tabs[i].id + '">' + tabs[i].label + '</button>';
}
html += '</div>';
return html;
}
function render(container) {
container.innerHTML = ''
+ '<div class="page-header"><h2>🔌 PLC 逻辑配置</h2><p>管理 PLC 逻辑图 — 信号源 / 逻辑图可视化 / 在线编辑</p></div>'
+ renderTabs()
+ '<div id="plc-cfg-content"></div>';
// 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 = ''
+ '<div class="plc-sig-header">'
+ '<input type="text" id="plc-sig-search" placeholder="搜索 saddr / desc...">'
+ '<button class="btn btn-primary" id="plc-sig-reload">🔄 重新加载</button>'
+ '<span style="margin-left:auto;color:#999;font-size:13px" id="plc-sig-count"></span>'
+ '</div>'
+ '<div class="plc-sig-table-wrap"><table class="plc-sig-table" id="plc-sig-table">'
+ '<thead><tr><th>序号 (no)</th><th>短地址 (saddr)</th><th>描述 (desc)</th></tr></thead>'
+ '<tbody id="plc-sig-tbody"></tbody>'
+ '</table></div>';
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 += '<tr>'
+ '<td>' + s.no + '</td>'
+ '<td><code>' + escHtml(s.saddr) + '</code></td>'
+ '<td>' + escHtml(s.desc) + '</td>'
+ '</tr>';
}
document.getElementById('plc-sig-tbody').innerHTML = html;
}
/* ================================================================
* Tab 2: 逻辑图查看器 — Canvas 可视化 Reclose_logic.txt
* ================================================================ */
function renderViewerTab(container) {
container.innerHTML = ''
+ '<div class="plc-viewer-toolbar">'
+ '<select id="plc-view-graph" style="min-width:200px"><option value="">-- 选择逻辑图 --</option></select>'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-outline" id="plc-view-zoomin">🔍+</button>'
+ '<button class="btn btn-outline" id="plc-view-zoomout">🔍-</button>'
+ '<button class="btn btn-outline" id="plc-view-reset">↺ 重置</button>'
+ '<button class="btn btn-primary" id="plc-view-reload">🔄 刷新</button>'
+ '</div>'
+ '<div class="plc-canvas-wrap" id="plc-view-canvas-wrap">'
+ '<canvas id="plc-view-canvas"></canvas>'
+ '</div>'
+ '<div class="plc-view-legend" id="plc-view-legend"></div>';
// 图例
var legendHtml = '<span style="font-weight:700">图例:</span> ';
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 += '<span style="display:inline-block;margin:2px 8px;padding:2px 8px;border-radius:4px;background:' + info.color + ';color:#fff;font-size:12px">' + info.icon + ' ' + info.name + '</span>';
}
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 信号(无 <Inputs> 块时也需创建链接)
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 = '<option value="">-- 选择逻辑图 --</option>';
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 += '<option value="' + i + '">' + escHtml(label) + '</option>';
}
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 = ''
+ '<div class="plc-editor-toolbar">'
+ '<input type="text" id="plc-edit-key" placeholder="逻辑图 Key" value="' + escHtml(editKey) + '" style="width:140px">'
+ '<input type="text" id="plc-edit-name" placeholder="逻辑图名称" value="' + escHtml(editName) + '" style="width:160px">'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-success" id="plc-edit-add-in">📥 新增输入</button>'
+ '<button class="btn btn-outline" id="plc-edit-add-gate">🧮 新增运算</button>'
+ '<button class="btn btn-info" id="plc-edit-add-out">📤 新增输出</button>'
+ '<button class="btn btn-outline" id="plc-edit-zoomin">🔍+</button>'
+ '<button class="btn btn-outline" id="plc-edit-zoomout">🔍-</button>'
+ '<button class="btn btn-warn" id="plc-edit-modify">✏️ 修改模块</button>'
+ '<button class="btn btn-danger" id="plc-edit-del">🗑 删除模块</button>'
+ '<button class="btn btn-primary" id="plc-edit-generate">✅ 生成配置</button>'
+ '</div>'
+ '<div class="plc-editor-main">'
+ '<div class="plc-canvas-wrap" id="plc-edit-canvas-wrap">'
+ '<canvas id="plc-edit-canvas" tabindex="0"></canvas>'
+ '</div>'
+ '</div>'
+ '<div class="plc-editor-info" id="plc-edit-info">'
+ '💡 双击模块可修改配置 | 单击选中/引脚连线 | 右键删除连线 | 滚轮缩放 | 按住空白拖动画布'
+ '</div>';
// 工具栏事件
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:450px">'
+ '<h3>🗑 确认删除</h3>'
+ '<p style="margin:12px 0;font-size:14px">确定删除 <strong style="color:#f44336">' + escHtml(label) + '</strong> 及其所有连线?</p>'
+ '<div style="margin-top:16px;display:flex;gap:8px">'
+ '<button class="btn btn-danger" id="plc-modal-ok">🗑 确认删除</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:380px">'
+ '<h3>⏱️ ' + info.name + ' — 修改延时</h3>'
+ '<p style="color:#999">当前延时: <strong>' + curMs + ' ms</strong></p>'
+ '<input type="number" id="plc-delay-input" value="' + curMs + '" min="0" max="600000" step="100"'
+ ' style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:#2a2a3e;color:#fff;font-size:16px;margin:8px 0">'
+ '<div style="margin-top:12px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-ok">💾 保存</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:420px">'
+ '<h3>🔒 ' + info.name + ' — 连接信息</h3>'
+ '<p style="margin:8px 0"><strong>S (置位)</strong>: ' + (sSrc || '<span style="color:#f44336">未连接</span>') + '</p>'
+ '<p style="margin:8px 0"><strong>R (复位)</strong>: ' + (rSrc || '<span style="color:#f44336">未连接</span>') + '</p>'
+ '<p style="color:#999;font-size:12px;margin-top:12px">💡 拖动输出引脚到目标节点的 S/R 引脚来连线</p>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">关闭</button>'
+ '</div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>' + title + '</h3>'
+ '<p style="color:#999">当前: ' + escHtml(getNodeDisplayLabel(node)) + '</p>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
var isCurrent = (node.no === s.no);
html += '<div class="plc-modal-row' + (isCurrent ? ' plc-modal-current' : '') + '" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span>'
+ (isCurrent ? '<span style="float:right;color:#2196F3">✓ 当前</span>' : '')
+ '</div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:400px">'
+ '<h3>' + info.icon + ' ' + info.name + ' 配置</h3>'
+ '<p style="color:#999">当前输入节点数: <strong>' + curPins + '</strong></p>'
+ '<label style="display:block;margin:12px 0 4px">输入引脚数量 (' + minPins + '~' + maxPins + '):</label>'
+ '<input type="number" id="plc-gate-pins" value="' + curPins + '" min="' + minPins + '" max="' + maxPins + '" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;font-size:16px">'
+ '<div style="margin-top:16px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-save">💾 保存</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>📥 绑定输入信号</h3>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号 (no/saddr/desc)..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
html += '<div class="plc-modal-row" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span></div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>📤 绑定输出信号</h3>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号 (no/saddr/desc)..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
html += '<div class="plc-modal-row" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span></div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:500px">'
+ '<h3>🧮 选择运算模块</h3>'
+ '<div style="max-height:400px;overflow-y:auto">';
for (var i = 0; i < gates.length; i++) {
var g = gates[i];
html += '<div class="plc-modal-row" data-gate-type="' + g.type + '">'
+ '<strong>' + g.icon + ' ' + g.name + '</strong>'
+ '<br><span style="color:#999;font-size:12px">' + g.desc + '</span>'
+ '</div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
// 自定义 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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:380px">'
+ '<h3>⏱️ ' + info.name + ' — 延时配置</h3>'
+ '<p style="color:#999;margin:8px 0">请输入延时时间(毫秒)</p>'
+ '<input type="number" id="plc-delay-input" value="1000" min="0" max="600000" step="100"'
+ ' style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:#2a2a3e;color:#fff;font-size:16px;margin:8px 0">'
+ '<div style="margin-top:12px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-ok">✅ 确认添加</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
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 '<Signal no="' + n.id + '" x="' + Math.round(n._lx) + '" y="' + Math.round(n._ly) + '" />'; }
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→RRS 按出现顺序 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 '<Gate' + a + ' />';
}
/** 后序遍历分配 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 + '<Comb id="' + (cid !== undefined ? cid : 0) + '">\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 + '<Inputs>\n';
for (var d = 0; d < di.length; d++) s += innerIndent + ' ' + sigXml(editNodes[di[d]]) + '\n';
s += innerIndent + '</Inputs>\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 + '<Outputs>\n';
for (var o = 0; o < dos.length; o++) s += innerIndent + ' ' + sigXml(editNodes[dos[o]]) + '\n';
s += innerIndent + '</Outputs>\n';
}
s += indent + '</Comb>\n';
return s;
}
var xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<PLCConfig name="' + escXml(editName || 'PLC Logic') + '" key="' + escXml(editKey)
+ '" checksum="' + (editOnlineCfgId || '00000000') + '">\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 += ' <Chain gates="' + cGates.length + '" inputs="' + cIns.length + '" outputs="' + cOuts.length + '">\n';
if (cGates.length === 1) {
// 简单链: Inputs → Gate → Outputs
xml += ' <Inputs>\n';
for (var ii = 0; ii < cIns.length; ii++) xml += ' ' + sigXml(editNodes[cIns[ii]]) + '\n';
xml += ' </Inputs>\n';
xml += ' ' + gateTag(editNodes[cGates[0]], cGates[0]) + '\n';
xml += ' <Outputs>\n';
for (var oo = 0; oo < cOuts.length; oo++) xml += ' ' + sigXml(editNodes[cOuts[oo]]) + '\n';
xml += ' </Outputs>\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 的下游,只保留 BcombXml 从 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 += ' <Inputs>\n';
for (var di2 = 0; di2 < directInputs.length; di2++) {
xml += ' ' + sigXml(editNodes[directInputs[di2]]) + '\n';
}
xml += ' </Inputs>\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 += ' <Outputs>\n';
for (var oo = 0; oo < chainOuts.length; oo++) xml += ' ' + sigXml(editNodes[chainOuts[oo]]) + '\n';
xml += ' </Outputs>\n';
}
}
xml += ' </Chain>\n';
}
xml += '</PLCConfig>\n';
return xml;
}
function escXml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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 = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:700px">'
+ '<h3>✅ 配置预览 (XML)</h3>'
+ '<p>Key: <strong>' + escHtml(editKey) + '</strong> | 名称: <strong>' + escHtml(editName || 'PLC Logic') + '</strong></p>'
+ '<p>节点: ' + editNodes.length + ' | 连线: ' + editLinks.length + '</p>'
+ '<div style="max-height:250px;overflow:auto;background:#1a1a2e;color:#0f0;padding:12px;border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-wrap">' + escHtml(preview) + '</div>'
+ '<div style="margin-top:12px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-save">💾 保存 Reclose_logic.xml</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
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); });