import { LadderEngine } from '../engine/engine';
import type { Program, Rung, RungElement, Contact, Coil, Timer, Counter, LogicGate, ContactType, CoilType, TimerType, CounterType, GateType, Branch } from '../engine/types';
// ─── State ────────────────────────────────────────────────────────
let engine = new LadderEngine();
let currentProgram: Program = {
name: 'Untitled',
rungs: [],
cycleTime: 100,
};
let selectedElement: { rungIndex: number; elementIndex: number } | null = null;
let stopEngine: (() => void) | null = null;
// Track structure to avoid redundant full renders
let lastStructureKey = '';
// ─── DOM ──────────────────────────────────────────────────────────
const canvas = document.getElementById('ladder-canvas')!;
const emptyState = document.getElementById('empty-state')!;
const btnAddRung = document.getElementById('btn-add-rung')!;
const btnPlay = document.getElementById('btn-play')!;
const btnPause = document.getElementById('btn-pause')!;
const btnStop = document.getElementById('btn-stop')!;
const configPanel = document.getElementById('config-panel')!;
const configBody = document.getElementById('config-body')!;
const statusEngineText = document.getElementById('status-engine-text')!;
const configClose = document.getElementById('config-close')!;
configClose.onclick = () => {
configPanel.classList.remove('visible');
selectedElement = null;
};
const btnDeleteElement = document.getElementById('btn-delete-element')!;
btnDeleteElement.onclick = () => {
if (selectedElement) {
currentProgram.rungs[selectedElement.rungIndex].series.splice(selectedElement.elementIndex, 1);
engine.loadProgram(currentProgram);
configPanel.classList.remove('visible');
selectedElement = null;
}
};
// ─── Initialization ───────────────────────────────────────────────
engine.subscribe(() => {
syncUI();
});
function syncUI() {
const currentStructureKey = currentProgram.rungs.map(r => `${r.id}-${r.series.length}`).join('|');
if (currentStructureKey !== lastStructureKey) {
renderLadder();
lastStructureKey = currentStructureKey;
}
updateVisuals();
updateStatus();
}
// ─── Rung Management ─────────────────────────────────────────────
btnAddRung.addEventListener('click', () => {
const newRung: Rung = { id: `rung-${Date.now()}`, enabled: true, series: [] };
currentProgram.rungs.push(newRung);
engine.loadProgram(currentProgram);
});
btnPlay.addEventListener('click', () => {
engine.loadProgram(currentProgram);
stopEngine = engine.start();
btnPlay.style.display = 'none';
btnPause.style.display = '';
});
btnPause.addEventListener('click', () => {
if (stopEngine) { stopEngine(); stopEngine = null; }
btnPlay.style.display = '';
btnPause.style.display = 'none';
});
btnStop.addEventListener('click', () => {
if (stopEngine) { stopEngine(); stopEngine = null; }
engine.loadProgram(currentProgram);
btnPlay.style.display = '';
btnPause.style.display = 'none';
});
// ─── Drag & Drop ────────────────────────────────────
document.querySelectorAll('.tool-item').forEach(item => {
(item as HTMLElement).draggable = true;
item.addEventListener('dragstart', (e) => {
const tool = (e.target as HTMLElement).closest('.tool-item')?.getAttribute('data-tool');
if (tool) {
(e as DragEvent).dataTransfer?.setData('text/plain', tool);
}
});
});
// ─── Rendering (Structural) ───────────────────────────────────────
function renderLadder(): void {
canvas.innerHTML = '';
emptyState.style.display = currentProgram.rungs.length === 0 ? '' : 'none';
currentProgram.rungs.forEach((rung, idx) => {
const rungEl = document.createElement('div');
rungEl.className = 'rung' + (rung.enabled ? '' : ' disabled');
rungEl.dataset.rungId = rung.id;
// Rung number and left line
const num = document.createElement('div');
num.className = 'rung-number';
num.textContent = (idx + 1).toString();
rungEl.appendChild(num);
const lineLeft = document.createElement('div');
lineLeft.className = 'rung-line-left';
rungEl.appendChild(lineLeft);
// Drag & Drop
rungEl.addEventListener('dragover', (e) => {
e.preventDefault();
rungEl.classList.add('drag-over');
});
rungEl.addEventListener('dragleave', () => {
rungEl.classList.remove('drag-over');
});
rungEl.addEventListener('drop', (e) => {
e.preventDefault();
rungEl.classList.remove('drag-over');
const tool = (e as DragEvent).dataTransfer?.getData('text/plain');
if (tool) {
handleDrop(tool, idx, rungEl);
}
});
// Elements in the rung
rung.series.forEach((el, ei) => {
const elEl = createElementDOM(el, idx, ei);
rungEl.appendChild(elEl);
});
// Drop zone and right line
const dropZone = document.createElement('div');
dropZone.className = 'element-drop-zone';
rungEl.appendChild(dropZone);
const lineRight = document.createElement('div');
lineRight.className = 'rung-line-right';
rungEl.appendChild(lineRight);
canvas.appendChild(rungEl);
});
}
function createElementDOM(el: RungElement, rungIdx: number, elIdx: number): HTMLElement {
const elEl = document.createElement('div');
elEl.className = 'element';
elEl.textContent = el.type;
elEl.dataset.type = el.type;
elEl.dataset.rungIdx = rungIdx.toString();
elEl.dataset.elIdx = elIdx.toString();
if (el.type === 'contact') {
const contact = el as Contact;
elEl.dataset.address = contact.address;
elEl.dataset.contactType = contact.contactType;
} else if (el.type === 'coil') {
const coil = el as Coil;
elEl.dataset.address = coil.address;
elEl.dataset.coilType = coil.coilType;
} else if (el.type === 'timer') {
const timer = el as Timer;
elEl.dataset.instanceId = timer.instanceId;
} else if (el.type === 'counter') {
const counter = el as Counter;
elEl.dataset.instanceId = counter.instanceId;
} else if (el.type === 'gate') {
const gate = el as LogicGate;
elEl.dataset.gateType = gate.gateType;
gate.inputs.forEach((addr, i) => {
elEl.setAttribute(`data-gate-input-${i}`, addr);
});
} else if (el.type === 'branch') {
const branch = el as Branch;
elEl.classList.add('branch-element');
elEl.dataset.logic = branch.logic;
if (branch.paths.length === 1) {
elEl.classList.add('single-path');
}
branch.paths.forEach((path, pathIdx) => {
const pathEl = document.createElement('div');
pathEl.className = 'branch-path';
path.forEach((pathElItem, pathElIdx) => {
pathEl.appendChild(createElementDOM(pathElItem, rungIdx, pathElIdx));
});
elEl.appendChild(pathEl);
});
}
if (el.type === 'coil') {
elEl.style.marginLeft = 'auto';
}
elEl.onclick = (e) => {
e.stopPropagation();
selectedElement = { rungIndex: rungIdx, elementIndex: elIdx };
showConfigPanel(rungIdx, elIdx);
};
return elEl;
}
function handleDrop(tool: string, rungIdx: number, rungEl: HTMLElement) {
let newElement: any;
if (tool.startsWith('contact-')) {
newElement = { type: 'contact', contactType: tool.endsWith('no') ? 'NO' : 'NC', address: 'UNKNOWN' };
} else if (tool.startsWith('coil-')) {
newElement = { type: 'coil', coilType: tool.split('-')[1].toUpperCase() as any, address: 'UNKNOWN' };
} else if (tool.startsWith('timer-')) {
newElement = { type: 'timer', timerType: tool.split('-')[1].toUpperCase() as any, instanceId: `timer-${Date.now()}`, preset: 1000, accumulated: 0 };
} else if (tool.startsWith('counter-')) {
newElement = { type: 'counter', counterType: tool.split('-')[1].toUpperCase() as any, instanceId: `counter-${Date.now()}`, preset: 0, current: 0 };
} else if (tool.startsWith('gate-')) {
newElement = { type: 'gate', gateType: tool.split('-')[1].toUpperCase() as any, inputs: [], outputAddress: 'UNKNOWN' };
} else if (tool === 'branch') {
newElement = { type: 'branch', id: `branch-${Date.now()}`, logic: 'AND', paths: [[]] };
}
if (newElement) {
const series = currentProgram.rungs[rungIdx].series;
series.push(newElement);
const coilIndex = series.findIndex(el => el.type === 'coil');
if (coilIndex !== -1 && coilIndex !== series.length - 1) {
const [coil] = series.splice(coilIndex, 1);
series.push(coil);
}
engine.loadProgram(currentProgram);
}
}
// ─── Visual Updates (State-driven) ─────────────────────────────────
function updateVisuals(): void {
const state = engine.getState();
document.querySelectorAll('.element[data-type="contact"]').forEach(el => {
const address = el.dataset.address!;
const contactType = el.dataset.contactType!;
const input = state.inputs.get(address);
const inputValue = input ? input.value : false;
const isPassing = contactType === 'NO' ? !!inputValue : !inputValue;
el.classList.toggle('active', isPassing); });
document.querySelectorAll('.element[data-type="coil"]').forEach(el => {
const address = el.dataset.address!;
const output = state.outputs.get(address);
const isEnergized = !!output?.value;
el.classList.toggle('energized', isEnergized); });
document.querySelectorAll('.element[data-instance-id]').forEach(el => {
const instanceId = el.dataset.instanceId!;
const type = el.dataset.type!;
if (type === 'timer') {
const timer = state.timers.get(instanceId);
el.classList.toggle('running', !!timer && timer.accumulated > 0);
} else if (type === 'counter') {
const counter = state.counters.get(instanceId);
el.classList.toggle('active', !!counter && counter.current > 0);
}
});
}
function showConfigPanel(rungIdx: number, elIdx: number): void {
configPanel.classList.add('visible');
const element = currentProgram.rungs[rungIdx].series[elIdx];
const titleEl = document.getElementById('config-title')!;
titleEl.textContent = `Edit ${element.type.toUpperCase()}`;
configBody.innerHTML = '';
const form = document.createElement('form');
form.onsubmit = (e) => {
e.preventDefault();
const formData = new FormData(form);
const updatedElement = { ...element } as any;
if (element.type === 'contact') {
updatedElement.contactType = formData.get('contactType') as ContactType;
updatedElement.address = formData.get('address') as string;
} else if (element.type === 'coil') {
updatedElement.coilType = formData.get('coilType') as CoilType;
updatedElement.address = formData.get('address') as string;
} else if (element.type === 'timer') {
updatedElement.timerType = formData.get('timerType') as TimerType;
updatedElement.preset = Number(formData.get('preset'));
} else if (element.type === 'counter') {
updatedElement.counterType = formData.get('counterType') as CounterType;
updatedElement.preset = Number(formData.get('preset'));
updatedElement.resetAddress = formData.get('resetAddress') as string || undefined;
} else if (element.type === 'gate') {
updatedElement.gateType = formData.get('gateType') as GateType;
updatedElement.outputAddress = formData.get('outputAddress') as string;
const inputsInput = form.querySelector('input[name="inputs"]') as HTMLInputElement;
updatedElement.inputs = inputsInput.value.split(',').map((s: string) => s.trim()).filter((s: string) => s !== '');
} else if (element.type === 'branch') {
updatedElement.logic = formData.get('logic') as 'AND' | 'OR';
}
currentProgram.rungs[rungIdx].series[elIdx] = updatedElement;
engine.loadProgram(currentProgram);
configPanel.classList.remove('visible');
};
const addField = (label: string, name: string, type: 'text' | 'number' | 'select', options?: string[]) => {
const div = document.createElement('div');
div.className = 'config-field';
const lbl = document.createElement('label');
lbl.textContent = label;
div.appendChild(lbl);
let input: HTMLInputElement | HTMLSelectElement;
if (type === 'select' && options) {
input = document.createElement('select');
options.forEach(opt => {
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
if ((element as any)[name] === opt) o.selected = true;
input.appendChild(o);
});
input.name = name;
} else {
input = document.createElement('input');
input.type = type;
input.name = name;
(input as HTMLInputElement).value = (element as any)[name]?.toString() || '';
}
div.appendChild(input);
form.appendChild(div);
};
if (element.type === 'contact') {
addField('Contact Type', 'contactType', 'select', ['NO', 'NC']);
addField('Address', 'address', 'text');
} else if (element.type === 'coil') {
addField('Coil Type', 'coilType', 'select', ['OUTPUT', 'SET', 'RESET', 'TOGGLE']);
addField('Address', 'address', 'text');
} else if (element.type === 'timer') {
addField('Timer Type', 'timerType', 'select', ['TON', 'TOF', 'TP']);
addField('Preset (ms)', 'preset', 'number');
} else if (element.type === 'counter') {
addField('Counter Type', 'counterType', 'select', ['CTU', 'CTD', 'CTUD']);
addField('Preset', 'preset', 'number');
addField('Reset Address', 'resetAddress', 'text');
} else if (element.type === 'gate') {
addField('Gate Type', 'gateType', 'select', ['AND', 'OR', 'XOR', 'NOT', 'NAND', 'NOR']);
addField('Output Address', 'outputAddress', 'text');
const inputsDiv = document.createElement('div');
inputsDiv.className = 'config-field';
const lbl = document.createElement('label');
lbl.textContent = 'Inputs (comma separated)';
inputsDiv.appendChild(lbl);
const input = document.createElement('input');
input.type = 'text';
input.name = 'inputs';
input.value = (element as LogicGate).inputs.join(', ');
inputsDiv.appendChild(input);
form.appendChild(inputsDiv);
} else if (element.type === 'branch') {
addField('Logic', 'logic', 'select', ['AND', 'OR']);
}
const actionsDiv = document.createElement('div');
actionsDiv.className = 'config-actions';
const submitBtn = document.createElement('button');
submitBtn.type = 'submit';
submitBtn.textContent = 'Save';
submitBtn.className = 'btn';
submitBtn.style.flex = '1';
actionsDiv.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'btn';
cancelBtn.style.flex = '1';
cancelBtn.onclick = () => configPanel.classList.remove('visible');
actionsDiv.appendChild(cancelBtn);
form.appendChild(actionsDiv);
configBody.appendChild(form);
}
function updateStatus(): void {
const state = engine.getState();
statusEngineText.textContent = state.running ? 'Running' : 'Stopped';
statusEngineText.className = state.running ? 'status-running' : 'status-stopped';
}