2529 lines
95 KiB
JavaScript
2529 lines
95 KiB
JavaScript
/**
|
||
* @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,'>').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 = '<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→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 '<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 的下游,只保留 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 += ' <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,'&').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 = '<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); }); |