/* ═══════════════════════════════════════════════════════════════════
MechBase PLC — Studio 5000 Minimalist Ladder Canvas
═══════════════════════════════════════════════════════════════════ */
// ── Config ─────────────────────────────────────────────────────────
const POLL_INTERVAL = 150;
const CANVAS_PADDING = 30;
const NETWORK_HEIGHT = 150;
const RUNG_HEIGHT = 80;
const RAIL_WIDTH = 6;
const WIRE_H = 2;
const SYMBOL_SIZE = 30;
const ELEMENT_GAP = 15;
// ── Colors ──────────────────────────────────────────────────────────
const COLORS = {
RAIL: '#2c3e50', // Dark Slate (Studio 5000 style)
WIRE: '#7f8c8d', // Gray
WIRE_POWERED: '#27ae60', // Green
TEXT: '#2c3e50',
TEXT_DIM: '#95a5a6',
BACKGROUND: '#ecf0f1' // Light Gray
};
// ── Instruction definitions (Simplified) ───────────
const INST_INFO = {
'XIC': { cat: 'contact', label: 'XIC' },
'XIO': { cat: 'contact', label: 'XIO' },
'OTE': { cat: 'output', label: 'OTE' }
};
const CONTACT_TYPES = new Set(['XIC','XIO']);
const OUTPUT_TYPES = new Set(['OTE']);
// ── State ──────────────────────────────────────────────────────────
let networks = [];
let plcState = null;
let selectedNet = 0;
let editingElement = null; // {netNum, rungIdx, branchIdx, elemIdx, side}
let addTarget = null; // {netNum, rungIdx, branchIdx, side}
let hoveredElement = null;
let selectedElement = null; // {netNum, rungIdx, branchIdx, elemIdx, side}
let canvasHitAreas = [];
const canvas = document.getElementById('ladderCanvas');
const ctx = canvas.getContext('2d');
// ── Init ───────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
setupDragDrop();
setupPaletteTypes();
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
startPolling();
render();
});
function resizeCanvas() {
const wrapper = document.getElementById('canvasWrapper');
canvas.width = Math.max(800, wrapper.clientWidth - 20);
canvas.height = Math.max(400, networks.length * (NETWORK_HEIGHT + 10) + 40);
render();
}
function setupPaletteTypes() {
['editType', 'addType'].forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '';
Object.keys(INST_INFO).forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
sel.appendChild(opt);
});
});
}
// ── Drag and Drop ──────────────────────────────────────────────────
function setupDragDrop() {
const palette = document.getElementById('palette');
if (!palette) return;
palette.addEventListener('dragstart', e => {
if (e.target.classList.contains('palette-item')) {
e.dataTransfer.setData('text/plain', e.target.dataset.type);
e.dataTransfer.effectAllowed = 'copy';
}
});
canvas.addEventListener('dragover', e => {
e.preventDefault();
const { x, y } = getCanvasCoords(e);
const hit = findDropTarget(x, y);
if (hit) {
hoveredElement = hit;
render();
}
});
canvas.addEventListener('dragleave', () => {
hoveredElement = null;
render();
});
canvas.addEventListener('drop', e => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
if (!type) return;
const { x, y } = getCanvasCoords(e);
const hit = findDropTarget(x, y);
if (hit) {
openAddDialog(type, hit);
}
hoveredElement = null;
render();
});
}
function getCanvasCoords(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left + canvas.parentElement.scrollLeft,
y: e.clientY - rect.top + canvas.parentElement.scrollTop
};
}
function findDropTarget(mx, my) {
for (const area of canvasHitAreas) {
if (mx >= area.x && mx <= area.x + area.w && my >= area.y && my <= area.y + area.h) {
return area.target;
}
}
// Fallback: nearest rung
for (const area of canvasHitAreas) {
if (area.type === 'rung' && my >= area.y && my <= area.y + area.h) {
return { netNum: area.target.netNum, rungIdx: area.target.rungIdx, side: 'inline', branchIdx: -1, elemIdx: -1 };
}
}
return null;
}
// ── Canvas Rendering ───────────────────────────────────────────────
function render() {
if (networks.length === 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#888';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No networks — click "+ Network" or "Templates" to get started.', canvas.width / 2, 80);
return;
}
canvas.height = networks.length * (NETWORK_HEIGHT + 10) + 40;
canvasHitAreas = [];
// Background
ctx.fillStyle = COLORS.BACKGROUND;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const railX = 50;
const railEndX = canvas.width - 50;
networks.forEach((net, ni) => {
const netY = ni * (NETWORK_HEIGHT + 10) + 20;
renderNetwork(net, ni, railX, railEndX, netY);
});
// Hover highlight
if (hoveredElement) {
canvasHitAreas.forEach(area => {
if (area.target && JSON.stringify(area.target) === JSON.stringify(hoveredElement)) {
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(area.x - 2, area.y - 2, area.w + 4, area.h + 4);
ctx.setLineDash([]);
}
});
}
syncTree();
}
function renderNetwork(net, ni, railX, railEndX, netY) {
// Network background
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
const pad = 8;
ctx.fillRect(railX - pad, netY - 20, railEndX - railX + pad * 2, NETWORK_HEIGHT + 30);
ctx.strokeRect(railX - pad, netY - 20, railEndX - railX + pad * 2, NETWORK_HEIGHT + 30);
// Header
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(railX - pad, netY - 20, railEndX - railX + pad * 2, 20);
ctx.fillStyle = '#1a6dd4';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`Network ${ni}`, railX, netY - 7);
canvasHitAreas.push({
type: 'network',
x: railX - pad, y: netY - 20,
w: railEndX - railX + pad * 2, h: NETWORK_HEIGHT + 30,
target: { netNum: ni }
});
(net.rungs || []).forEach((rung, ri) => {
const rungY = netY + ri * RUNG_HEIGHT;
renderRung(rung, ri, rungY, ni, railX, railEndX);
});
}
function renderRung(rung, ri, rungY, netNum, railX, railEndX) {
const baseY = rungY + 40;
const powered = plcState && plcState.running;
const railColor = powered ? '#2ecc40' : COLORS.RAIL;
// Rung ID
ctx.fillStyle = COLORS.TEXT_DIM;
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(`R${ri}`, railX - 10, baseY + 4);
canvasHitAreas.push({
type: 'rung',
x: railX, y: rungY,
w: railEndX - railX, h: RUNG_HEIGHT,
target: { netNum, rungIdx: ri, side: 'inline', branchIdx: -1, elemIdx: -1 }
});
// Rails
ctx.strokeStyle = railColor;
ctx.lineWidth = RAIL_WIDTH;
ctx.beginPath();
ctx.moveTo(railX, baseY - 30); ctx.lineTo(railX, baseY + 30);
ctx.moveTo(railEndX, baseY - 30); ctx.lineTo(railEndX, baseY + 30);
ctx.stroke();
// Horizontal line
ctx.strokeStyle = railColor;
ctx.lineWidth = WIRE_H;
ctx.beginPath();
ctx.moveTo(railX, baseY);
ctx.lineTo(railEndX, baseY);
ctx.stroke();
const inlineElems = (rung.inline && rung.inline.elements) || [];
const outputs = rung.outputs || [];
// 1. Render Inline elements starting from the left rail
let inlineX = railX + 20;
inlineElems.forEach((elem, ei) => {
const info = INST_INFO[elem.type];
drawInstruction(ctx, elem, info, inlineX, baseY, 'inline', netNum, ri, -1, ei, powered);
inlineX += SYMBOL_SIZE + ELEMENT_GAP;
});
// 2. Render Output elements starting from the right rail (Industry Standard)
outputs.forEach((elem, oi) => {
const info = INST_INFO[elem.type];
const x = (railEndX - 20) - (outputs.length - 1 - oi) * (SYMBOL_SIZE + ELEMENT_GAP) - (SYMBOL_SIZE / 2);
drawInstruction(ctx, elem, info, x, baseY, 'output', netNum, ri, -1, oi, powered);
});
}
function drawInstruction(ctx, elem, info, x, y, side, netNum, rungIdx, branchIdx, elemIdx, powered) {
const isHovered = hoveredElement &&
hoveredElement.netNum === netNum &&
hoveredElement.rungIdx === rungIdx &&
hoveredElement.branchIdx === branchIdx &&
hoveredElement.elemIdx === elemIdx &&
hoveredElement.side === side;
const isSelected = selectedElement &&
selectedElement.netNum === netNum &&
selectedElement.rungIdx === rungIdx &&
selectedElement.branchIdx === branchIdx &&
selectedElement.elemIdx === elemIdx &&
selectedElement.side === side;
// Hit area for the element
canvasHitAreas.push({
type: 'element',
x: x - SYMBOL_SIZE/2, y: y - SYMBOL_SIZE/2,
w: SYMBOL_SIZE, h: SYMBOL_SIZE,
target: { netNum, rungIdx, branchIdx, elemIdx, side }
});
// 1. Draw Instruction Plate (The \"Box\")
const boxW = 42;
const boxH = 26;
ctx.fillStyle = '#ffffff';
ctx.shadowBlur = 3;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.fillRect(x - boxW/2, y - boxH/2, boxW, boxH);
ctx.shadowBlur = 0;
// 2. Draw Border / Selection Highlight
ctx.lineWidth = 1;
if (isHovered) {
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 2;
} else if (isSelected) {
// 1. Draw Instruction Plate (The \"Box\")
const boxW = 42;
const boxH = 26;
ctx.fillStyle = '#ffffff';
ctx.shadowBlur = 3;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.fillRect(x - boxW/2, y - boxH/2, boxW, boxH);
ctx.shadowBlur = 0;
// 2. Draw Border / Selection Highlight
ctx.lineWidth = 1;
if (isHovered) {
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 2;
} else if (isSelected) {
ctx.strokeStyle = '#2ecc40'; // Green for selected
ctx.lineWidth = 2;
} else {
ctx.strokeStyle = '#bdc3c7'; // Light gray border
ctx.lineWidth = 1;
}
ctx.strokeRect(x - boxW/2, y - boxH/2, boxW, boxH);
// 3. Draw Symbol
ctx.lineWidth = 2;
ctx.strokeStyle = isSelected ? '#2ecc40' : (isHovered ? '#4a90d9' : COLORS.RAIL);
if (elem.type === 'XIC' || elem.type === 'XIO') {
// Contact: | |
ctx.beginPath();
ctx.moveTo(x - 6, y - 10); ctx.lineTo(x - 6, y + 10);
ctx.moveTo(x + 6, y - 10); ctx.lineTo(x + 6, y + 10);
ctx.stroke();
if (elem.type === 'XIO') {
// Diagonal for NC contact
ctx.beginPath();
ctx.moveTo(x - 6, y + 10); ctx.lineTo(x + 6, y - 10);
ctx.stroke();
}
} else if (elem.type === 'OTE') {
// Coil: ( )
ctx.beginPath();
ctx.arc(x - 6, y, 6, Math.PI/2, -Math.PI/2);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + 6, y, 6, -Math.PI/2, Math.PI/2);
ctx.stroke();
}
// 4. Draw Tag Text (Above the box)
ctx.fillStyle = COLORS.TEXT;
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(elem.tag || '?', x, y - (boxH / 2) - 4);
}
// ── Interaction ────────────────────────────────────────────────────
canvas.addEventListener('click', e => {
const { x, y } = getCanvasCoords(e);
for (const area of canvasHitAreas) {
if (area.type === 'element' && x >= area.x && x <= area.x + area.w && y >= area.y && y <= area.y + area.h) {
selectedElement = area.target;
render();
return;
}
}
for (const area of canvasHitAreas) {
if (area.type === 'rung' && y >= area.y && y <= area.y + area.h) {
openAddDialog('XIC', area.target);
return;
}
}
});
canvas.addEventListener('dblclick', e => {
const { x, y } = getCanvasCoords(e);
for (const area of canvasHitAreas) {
if (area.type === 'element' && x >= area.x && x <= area.x + area.w && y >= area.y && y <= area.y + area.h) {
openEditDialog(area.target);
return;
}
}
});
function openEditDialog(target) {
const net = networks[target.netNum];
const rung = net.rungs[target.rungIdx];
let elem;
if (target.side === 'output') elem = rung.outputs[target.elemIdx];
else if (target.side === 'parallel') elem = rung.parallel[target.branchIdx]?.elements[target.elemIdx];
else elem = rung.inline.elements[target.elemIdx];
if (!elem) return;
editingElement = { ...target };
const sel = document.getElementById('editType');
sel.innerHTML = '';
Object.keys(INST_INFO).forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === elem.type) opt.selected = true;
sel.appendChild(opt);
});
document.getElementById('editTag').value = elem.tag || '';
document.getElementById('editDialog').classList.add('active');
}
function closeEditDialog() {
document.getElementById('editDialog').classList.remove('active');
editingElement = null;
}
function saveEdit() {
if (!editingElement) return;
const { netNum, rungIdx, branchIdx, elemIdx, side } = editingElement;
const net = networks[netNum];
const rung = net.rungs[rungIdx];
let elem;
if (side === 'output') elem = rung.outputs[elemIdx];
else if (side === 'parallel') elem = rung.parallel[branchIdx]?.elements[elemIdx];
else elem = rung.inline.elements[elemIdx];
elem.type = document.getElementById('editType').value;
elem.tag = document.getElementById('editTag').value;
closeEditDialog();
render();
}
function deleteSelectedElement() {
if (!editingElement) return;
const { netNum, rungIdx, branchIdx, elemIdx, side } = editingElement;
const net = networks[netNum];
const rung = net.rungs[rungIdx];
if (side === 'output') rung.outputs.splice(elemIdx, 1);
else if (side === 'parallel') rung.parallel[branchIdx]?.elements.splice(elemIdx, 1);
else rung.inline.elements.splice(elemIdx, 1);
closeEditDialog();
render();
}
function openAddDialog(defaultType, target) {
addTarget = { ...target };
const sel = document.getElementById('addType');
sel.innerHTML = '';
Object.keys(INST_INFO).forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === defaultType) opt.selected = true;
sel.appendChild(opt);
});
document.getElementById('addTag').value = 'Tag';
document.getElementById('addDialog').classList.add('active');
}
function closeAddDialog() {
document.getElementById('addDialog').classList.remove('active');
addTarget = null;
}
function confirmAdd() {
if (!addTarget) return;
const type = document.getElementById('addType').value;
const tag = document.getElementById('addTag').value;
const net = networks[addTarget.netNum];
const rung = net.rungs[addTarget.rungIdx];
const elem = { type, tag };
if (type === 'OTE') rung.outputs.push(elem);
else rung.inline.elements.push(elem);
closeAddDialog();
render();
}
// ── Network / Rung Management ──────────────────────────────────────
function addNetwork() {
networks.push({
number: networks.length,
rungs: [{ number: 0, inline: { elements: [] }, outputs: [] }],
});
render();
}
function addRungToSelected() {
const net = networks[selectedNet];
net.rungs.push({ number: net.rungs.length, inline: { elements: [] }, outputs: [] });
render();
}
function scrollToNetwork(ni) {
const wrapper = document.getElementById('canvasWrapper');
const y = ni * (NETWORK_HEIGHT + 10) + 20 - wrapper.scrollTop;
wrapper.scrollTo({ top: y, behavior: 'smooth' });
selectedNet = ni;
}
function syncTree() {
const netsDiv = document.getElementById('treeNets');
if (!netsDiv) return;
const existing = netsDiv.querySelectorAll('.tree-net');
if (existing.length === networks.length) return;
netsDiv.innerHTML = '';
networks.forEach((net, i) => {
const div = document.createElement('div');
div.className = 'tree-net' + (i === selectedNet ? ' active' : '');
div.textContent = `Network ${i}`;
div.addEventListener('click', () => scrollToNetwork(i));
netsDiv.appendChild(div);
});
}
function clearProgram() {
if (confirm('Clear all?')) { networks = []; render(); }
}
// ── I/O Monitor ────────────────────────────────────────────────────
async function startPolling() {
while (true) {
try {
const resp = await fetch('/api/state');
if (resp.ok) {
plcState = await resp.json();
updateStatus();
renderIO('ioInputs', plcState.inputs || {}, 'I');
renderIO('ioOutputs', plcState.outputs || {}, 'Q');
renderIO('ioMemory', plcState.memory || {}, 'M');
}
} catch (e) {}
await new Promise(r => setTimeout(r, POLL_INTERVAL));
}
}
function updateStatus() {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
const btn = document.getElementById('btnRun');
if (!plcState) return;
if (plcState.running) {
dot.classList.add('running');
text.textContent = 'RUN';
btn.textContent = '■ STOP';
btn.classList.add('running');
} else {
dot.classList.remove('running');
text.textContent = 'STOP';
btn.textContent = '▶ RUN';
btn.classList.remove('running');
}
}
function renderIO(containerId, ioMap, prefix) {
const container = document.getElementById(containerId);
if (!container) return;
const keys = Object.keys(ioMap).filter(k => k.startsWith(prefix)).sort();
container.innerHTML = '';
keys.forEach(tag => {
const div = document.createElement('div');
div.className = 'io-item';
const val = ioMap[tag];
div.innerHTML = `<span class="tag">${tag}</span><span class="state ${val ? 'on' : 'off'}">${val ? 'ON' : 'OFF'}</span>`;
container.appendChild(div);
});
}
// ── L5X Import Logic ─────────────────────────────────────────────
/**
* Parses an L5X <Text> string into a structured format for the ladder editor.
*/
function parseL5XText(text) {
const result = {
inline: { elements: [] },
outputs: [],
parallel: []
};
const parseSegment = (segment) => {
const elements = [];
const outputs = [];
const pattern = /([A-Z]+)\s*\(([^)]+)\)/g;
let match;
while ((match = pattern.exec(segment)) !== null) {
const type = match[1];
const label = match[2].trim();
const obj = { type, tag: label };
if (['OTE', 'OTL', 'OTU', 'RES'].includes(type)) {
outputs.push(obj);
} else {
elements.push(obj);
}
}
return { elements, outputs };
};
const branchRegex = /\[(.*?)\]/g;
const branches = [];
let branchMatch;
let textWithoutBranches = text;
while ((branchMatch = branchRegex.exec(text)) !== null) {
branches.push(branchMatch[1]);
}
textWithoutBranches = text.replace(branchRegex, '');
const main = parseSegment(textWithoutBranches);
result.inline.elements = main.elements;
result.outputs = main.outputs;
for (const branchContent of branches) {
const segments = branchContent.split(',');
for (const seg of segments) {
const bParsed = parseSegment(seg);
if (bParsed.elements.length > 0 || bParsed.outputs.length > 0) {
result.parallel.push({
inline: { elements: bParsed.elements },
outputs: bParsed.outputs
});
}
}
}
return result;
}
function loadL5X(xmlString) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "text/xml");
const rungs = xmlDoc.getElementsByTagName("Rung");
if (rungs.length === 0) {
alert("No <Rung> elements found in L5X.");
return;
}
const newNetworks = [];
for (let i = 0; i < rungs.length; i++) {
const rung = rungs[i];
const textElement = rung.getElementsByTagName("Text")[0];
const commentElement = rung.getElementsByTagName("Comment")[0];
if (textElement) {
const text = textElement.textContent;
const comment = commentElement ? commentElement.textContent : "";
const parsedRung = parseL5XText(text);
newNetworks.push({
id: `rung_${i}`,
comment: comment,
inline: parsedRung.inline,
outputs: parsedRung.outputs,
parallel: parsedRung.parallel
});
}
}
networks = newNetworks;
render();
console.log(`Successfully loaded ${newNetworks.length} rungs from L5X.`);
}
function showL5xImport() {
document.getElementById('l5xImportDialog').classList.add('active');
document.getElementById('l5xInfo').textContent = "Note: To test, paste your L5X XML content into the console and call loadL5X(xmlString).";
}
function closeL5xImport() {
document.getElementById('l5xImportDialog').classList.remove('active');
}
function confirmL5xImport() {
const content = prompt("Paste the content of your .L5X file here:");
if (content) {
loadL5X(content);
closeL5xImport();
}
}