import React from 'react';
import { Program, RungElement, Branch } from '../../engine/types';
import { Element } from './Element';
interface LadderProps {
program: Program;
engineState: any;
onElementClick: (rungIdx: number, elIdx: number, element: RungElement) => void;
onDropElement: (tool: string, rungIdx: number) => void;
onMoveElement: (fromRungIdx: number, fromElIdx: number, toRungIdx: number, toElIdx: number) => void;
}
// ─── Layout constants ───────────────────────────────────────────────────────
const RAIL_WIDTH = 8; // visual stroke width for power rails
const WIRE_W = 2; // wire stroke width (inactive)
const WIRE_W_ACTIVE = 2.5; // wire stroke width (active)
const EL_HALF_W = 28; // half of element plate width (56px total)
const EL_HALF_H_NORMAL = 17; // half height for standard elements
const EL_HALF_H_TALL = 32; // half height for timer/counter
// ─── Layout helpers ────────────────────────────────────────────────────────────
/** How many horizontal slot-units an element occupies. Branches get more slots. */
function getElementSlots(el: RungElement): number {
if (el.type === 'branch') {
const branch = el as Branch;
const maxLen = branch.paths.reduce((m, p) => Math.max(m, p.length), 1);
return Math.max(2, maxLen);
}
return 1;
}
/** Height of one rung — expands when branches have many paths. */
function getRungHeight(series: RungElement[]): number {
let maxPaths = 1;
for (const el of series) {
if (el.type === 'branch') {
maxPaths = Math.max(maxPaths, (el as Branch).paths.length);
}
}
// 60px per path slot, 40px vertical margin; minimum 96px for single-path rungs
return Math.max(96, maxPaths * 60 + 40);
}
/** Height for an individual element (timers/counters need more vertical space). */
function getElHeight(el: RungElement): number {
return el.type === 'timer' || el.type === 'counter' ? EL_HALF_H_TALL * 2 : EL_HALF_H_NORMAL * 2;
}
// ─── Component ─────────────────────────────────────────────────────────────────
export const Ladder: React.FC<LadderProps> = ({
program,
engineState,
onElementClick,
onDropElement,
onMoveElement,
}) => {
const padding = 72; // space for rung number badge + margin
const svgWidth = 960;
const railX_left = padding - 12;
const railX_right = svgWidth - padding + 12;
// Pre-calculate rung heights and cumulative Y positions
const rungHeights = program.rungs.map(r => getRungHeight(r.series));
const rungYPositions: number[] = [];
let yAccum = 28; // top margin
for (const h of rungHeights) {
rungYPositions.push(yAccum);
yAccum += h;
}
const svgHeight = yAccum + 28; // bottom margin
// ─── Drop handling ─────────────────────────────────────────────────────────
const handleDrop = (e: React.DragEvent, rungIdx: number) => {
e.preventDefault();
const tool = e.dataTransfer.getData('text/plain');
const elementData = e.dataTransfer.getData('application/json');
if (elementData && elementData !== 'element') {
try {
const parsed = JSON.parse(elementData);
if (
parsed &&
typeof parsed.fromRungIdx === 'number' &&
typeof parsed.fromElIdx === 'number'
) {
const { fromRungIdx, fromElIdx } = parsed;
const rect = e.currentTarget as SVGRectElement;
const bounding = rect.getBoundingClientRect();
const offsetX = e.clientX - bounding.left;
const N = program.rungs[rungIdx].series.length;
const toElIdx = Math.min(N, Math.floor((offsetX / bounding.width) * (N + 1)));
onMoveElement(fromRungIdx, fromElIdx, rungIdx, toElIdx);
return;
}
} catch (err) {
console.error('Failed to parse element data', err);
}
}
if (tool && tool !== 'element') {
onDropElement(tool, rungIdx);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// ─── Power-flow evaluation ─────────────────────────────────────────────────
/** Is this contact element currently passing power? */
const getContactActive = (el: RungElement): boolean => {
if (el.type !== 'contact') return true; // non-contacts don't block here
const input = engineState.inputs.get(el.address);
const val = input ? input.value : false;
return el.contactType === 'NO' ? !!val : !val;
};
/** Is power flowing through every element in a branch path? */
const isPathPowered = (path: RungElement[]): boolean => {
if (!engineState.running) return false;
for (const el of path) {
if (el.type === 'contact') {
if (!getContactActive(el)) return false;
} else if (el.type === 'branch') {
if (!isBranchPowered(el as Branch)) return false;
}
}
return true;
};
/** Is this branch element powered (per its OR/AND logic)? */
const isBranchPowered = (branch: Branch): boolean => {
if (!engineState.running) return false;
if (branch.logic === 'OR') {
return branch.paths.some(p => isPathPowered(p));
}
return branch.paths.every(p => isPathPowered(p));
};
/**
* Is power flowing up to and including element `elIdx` in rung `rungIdx`?
* Used for colouring wires and elements on the main rung line.
*/
const isRungActive = (rungIdx: number, elIdx: number): boolean => {
const rung = program.rungs[rungIdx];
if (!rung || !engineState.running) return false;
let powered = true;
for (let i = 0; i <= elIdx; i++) {
const el = rung.series[i];
if (!el) { powered = false; break; }
if (el.type === 'contact') {
if (!getContactActive(el)) { powered = false; break; }
} else if (el.type === 'branch') {
if (!isBranchPowered(el as Branch)) { powered = false; break; }
}
}
return powered;
};
/** isActive / isEnergized props for the Element component. */
const getElementStates = (element: RungElement) => {
let isActive = false;
let isEnergized = false;
if (element.type === 'contact') {
const input = engineState.inputs.get(element.address);
const val = input ? input.value : false;
isActive = element.contactType === 'NO' ? !!val : !val;
} else if (element.type === 'coil') {
const output = engineState.outputs.get(element.address);
isEnergized = !!output?.value;
}
return { isActive, isEnergized };
};
// ─── Wire helpers ──────────────────────────────────────────────────────────
const wireStyle = (active: boolean): React.CSSProperties => ({
stroke: active ? 'var(--wire-active)' : 'var(--wire-inactive)',
strokeWidth: active ? WIRE_W_ACTIVE : WIRE_W,
strokeLinecap: 'round' as const,
filter: active ? 'drop-shadow(0 0 3px var(--success-glow))' : undefined,
transition: 'stroke 0.2s ease, filter 0.2s ease',
});
const dotStyle = (active: boolean): React.CSSProperties => ({
fill: active ? 'var(--wire-active)' : 'var(--wire-inactive)',
filter: active ? 'drop-shadow(0 0 2px var(--success-glow))' : undefined,
transition: 'fill 0.2s ease',
});
// ─── Path element rendering (reused for branch paths) ─────────────────────
/**
* Renders a series of elements within a path (branch path or stub).
* Elements are evenly spaced between `startX` and `endX` at height `pathCenterY`.
*/
const renderPathElements = (
path: RungElement[],
pathCenterY: number,
startX: number,
endX: number,
rungIdx: number,
parentElIdx: number,
wireActive: boolean,
): React.ReactNode => {
const ws = wireStyle(wireActive);
if (path.length === 0) {
// Empty path — through wire
return (
<line
x1={startX} y1={pathCenterY}
x2={endX} y2={pathCenterY}
style={ws}
/>
);
}
const pathWidth = endX - startX;
const elSpacing = pathWidth / (path.length + 1);
const nodes: React.ReactNode[] = [];
// Wire: startX → first element left edge
nodes.push(
<line key="w-start"
x1={startX} y1={pathCenterY}
x2={startX + elSpacing - EL_HALF_W} y2={pathCenterY}
style={ws}
/>
);
path.forEach((el, i) => {
const elCenterX = startX + (i + 1) * elSpacing;
const elH = getElHeight(el);
const { isActive, isEnergized } = getElementStates(el);
// Wire between elements (i > 0) — with dot connectors
if (i > 0) {
const wireX1 = startX + i * elSpacing + EL_HALF_W;
const wireX2 = elCenterX - EL_HALF_W;
nodes.push(
<React.Fragment key={`w-${i}`}>
<line
x1={wireX1} y1={pathCenterY}
x2={wireX2} y2={pathCenterY}
style={ws}
/>
{/* Junction dot at left side of element */}
<circle cx={wireX1} cy={pathCenterY} r={2} style={dotStyle(wireActive)} />
</React.Fragment>
);
}
nodes.push(
<Element
key={`e-${i}`}
element={el}
x={elCenterX - EL_HALF_W}
y={pathCenterY - elH / 2}
width={EL_HALF_W * 2}
height={elH}
isActive={isActive}
isEnergized={isEnergized}
isRunning={engineState.running}
onClick={(e) => {
e.stopPropagation();
onElementClick(rungIdx, parentElIdx, el);
}}
onDragStart={(e) => {
e.dataTransfer.setData(
'application/json',
JSON.stringify({ fromRungIdx: rungIdx, fromElIdx: parentElIdx }),
);
e.dataTransfer.setData('text/plain', 'element');
}}
/>
);
});
// Wire: last element right edge → endX
const lastRightX = startX + path.length * elSpacing + EL_HALF_W;
nodes.push(
<line key="w-end"
x1={lastRightX} y1={pathCenterY}
x2={endX} y2={pathCenterY}
style={ws}
/>
);
return <g>{nodes}</g>;
};
// ─── Branch inline rendering ───────────────────────────────────────────────
/**
* Renders a Branch element in Studio 5000 style:
* vertical bus bars on left/right, one horizontal path per branch.paths entry.
*/
const renderBranch = (
branch: Branch,
leftBusX: number,
rightBusX: number,
centerY: number,
rungIdx: number,
elIdx: number,
inputPowered: boolean,
): React.ReactNode => {
const pathCount = branch.paths.length;
const pathSlotH = 60; // px per path slot (taller for professional spacing)
const branchH = pathCount * pathSlotH;
const branchTopY = centerY - branchH / 2;
const branchWidth = rightBusX - leftBusX;
const branchIsPowered = inputPowered && isBranchPowered(branch);
const busColor = branchIsPowered ? 'var(--wire-active)' : 'var(--wire-inactive)';
const busFilter = branchIsPowered ? 'drop-shadow(0 0 4px var(--success-glow))' : undefined;
return (
<g key={`branch-${elIdx}`}>
{/* Branch container outline — very subtle */}
<rect
x={leftBusX - 2} y={branchTopY - 4}
width={branchWidth + 4} height={branchH + 8}
fill="rgba(255,255,255,0.25)"
stroke="rgba(0,0,0,0.08)"
strokeWidth={0.75}
strokeDasharray="5,3"
rx={3}
/>
{/* Logic badge (OR / AND) — clean pill shape */}
<rect
x={leftBusX + branchWidth / 2 - 18}
y={branchTopY - 16}
width={36}
height={12}
fill={branchIsPowered ? 'var(--success)' : 'var(--text-secondary)'}
rx={6}
opacity={0.85}
/>
<text
x={leftBusX + branchWidth / 2}
y={branchTopY - 7}
textAnchor="middle"
fontSize="7.5"
fontWeight="700"
fill="#ffffff"
fontFamily="'Segoe UI', system-ui, sans-serif"
letterSpacing="0.04em"
>
{branch.logic}
</text>
{/* Left vertical bus bar */}
<line
x1={leftBusX} y1={branchTopY}
x2={leftBusX} y2={branchTopY + branchH}
stroke={busColor}
strokeWidth={3}
strokeLinecap="round"
style={{ filter: busFilter, transition: 'stroke 0.2s ease' }}
/>
{/* Right vertical bus bar */}
<line
x1={rightBusX} y1={branchTopY}
x2={rightBusX} y2={branchTopY + branchH}
stroke={busColor}
strokeWidth={3}
strokeLinecap="round"
style={{ filter: busFilter, transition: 'stroke 0.2s ease' }}
/>
{/* Branch paths */}
{branch.paths.map((path, pathIdx) => {
const pathCenterY = branchTopY + pathIdx * pathSlotH + pathSlotH / 2;
const pathActive = inputPowered && isPathPowered(path);
const pathBusColor = pathActive ? 'var(--wire-active)' : 'var(--wire-inactive)';
return (
<g key={`path-${pathIdx}`}>
{/* Path number label — small, left-aligned */}
<text
x={leftBusX + 5}
y={branchTopY + pathIdx * pathSlotH + 10}
fontSize="7"
fontWeight="600"
fill={pathActive ? 'var(--success)' : 'rgba(0,0,0,0.28)'}
fontFamily="'SF Mono', 'Consolas', monospace"
style={{ transition: 'fill 0.2s ease' }}
>
{pathIdx + 1}
</text>
{/* Short horizontal tap from bus bar to path elements */}
<line
x1={leftBusX} y1={pathCenterY}
x2={leftBusX + 6} y2={pathCenterY}
stroke={pathBusColor}
strokeWidth={WIRE_W}
strokeLinecap="round"
/>
<line
x1={rightBusX - 6} y1={pathCenterY}
x2={rightBusX} y2={pathCenterY}
stroke={pathBusColor}
strokeWidth={WIRE_W}
strokeLinecap="round"
/>
{renderPathElements(
path,
pathCenterY,
leftBusX + 6,
rightBusX - 6,
rungIdx,
elIdx,
pathActive,
)}
</g>
);
})}
</g>
);
};
// ─── SVG render ────────────────────────────────────────────────────────────
return (
<svg
width="100%"
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
style={{ maxWidth: svgWidth, fontFamily: "'Segoe UI', system-ui, sans-serif" }}
>
<defs>
{/* Subtle dot grid — very light, just enough to show scale */}
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.6" fill="rgba(0,0,0,0.06)" />
</pattern>
{/* Rail gradient — dark steel with slight sheen */}
<linearGradient id="rail-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0d1922" />
<stop offset="40%" stopColor="#1a2634" />
<stop offset="60%" stopColor="#243447" />
<stop offset="100%" stopColor="#0d1922" />
</linearGradient>
{/* Active rail gradient */}
<linearGradient id="rail-active-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0f5c28" />
<stop offset="50%" stopColor="#1a8a3c" />
<stop offset="100%" stopColor="#0f5c28" />
</linearGradient>
{/* Drop shadow filter for elements */}
<filter id="el-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="rgba(0,0,0,0.14)" />
</filter>
{/* Active glow filter */}
<filter id="active-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="rgba(26,138,60,0.5)" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Canvas background — clean light gray */}
<rect x="0" y="0" width={svgWidth} height={svgHeight} fill="#f0f2f4" />
{/* Dot grid overlay */}
<rect x="0" y="0" width={svgWidth} height={svgHeight} fill="url(#grid)" />
{/* ── Power Rails ── */}
{/* Left rail — double-line effect for depth */}
<rect
x={railX_left - RAIL_WIDTH / 2 - 1}
y={0}
width={RAIL_WIDTH + 2}
height={svgHeight}
fill="url(#rail-grad)"
/>
{/* Highlight edge */}
<line
x1={railX_left - RAIL_WIDTH / 2} y1={0}
x2={railX_left - RAIL_WIDTH / 2} y2={svgHeight}
stroke="rgba(255,255,255,0.12)"
strokeWidth={1}
/>
{/* Rail label */}
<text
x={railX_left}
y={14}
textAnchor="middle"
fontSize="8"
fontWeight="700"
fill="rgba(255,255,255,0.55)"
fontFamily="'Segoe UI', system-ui, sans-serif"
letterSpacing="0.06em"
>
L1
</text>
{/* Right rail */}
<rect
x={railX_right - RAIL_WIDTH / 2 - 1}
y={0}
width={RAIL_WIDTH + 2}
height={svgHeight}
fill="url(#rail-grad)"
/>
<line
x1={railX_right - RAIL_WIDTH / 2} y1={0}
x2={railX_right - RAIL_WIDTH / 2} y2={svgHeight}
stroke="rgba(255,255,255,0.12)"
strokeWidth={1}
/>
<text
x={railX_right}
y={14}
textAnchor="middle"
fontSize="8"
fontWeight="700"
fill="rgba(255,255,255,0.55)"
fontFamily="'Segoe UI', system-ui, sans-serif"
letterSpacing="0.06em"
>
N
</text>
{/* ── Rungs ── */}
{program.rungs.map((rung, rungIdx) => {
const rungY = rungYPositions[rungIdx];
const rungH = rungHeights[rungIdx];
const rungWidth = railX_right - railX_left;
const centerY = rungY + rungH / 2;
const isOdd = rungIdx % 2 === 1;
// ── Slot-based layout ───────────────────────────────────────────────
let cumulativeSlots = 0;
const elMeta: { cs: number; slots: number }[] = [];
for (const el of rung.series) {
const slots = getElementSlots(el);
elMeta.push({ cs: cumulativeSlots, slots });
cumulativeSlots += slots;
}
const totalSlots = cumulativeSlots;
const slotWidth = totalSlots > 0 ? rungWidth / (totalSlots + 1) : rungWidth;
const getLeftX = (i: number): number => {
const { cs, slots } = elMeta[i];
const el = rung.series[i];
if (el.type === 'branch') {
return railX_left + cs * slotWidth + slotWidth / 2;
}
return railX_left + (cs + 1) * slotWidth - EL_HALF_W;
};
const getRightX = (i: number): number => {
const { cs, slots } = elMeta[i];
const el = rung.series[i];
if (el.type === 'branch') {
return railX_left + (cs + slots) * slotWidth + slotWidth / 2;
}
return railX_left + (cs + 1) * slotWidth + EL_HALF_W;
};
const lastActive = rung.series.length > 0
? isRungActive(rungIdx, rung.series.length - 1)
: engineState.running;
return (
<g key={rung.id}>
{/* Rung background stripe — alternating subtle bands */}
<rect
x={railX_left + RAIL_WIDTH / 2}
y={rungY}
width={rungWidth - RAIL_WIDTH}
height={rungH}
fill={isOdd ? 'rgba(0,0,0,0.015)' : 'rgba(255,255,255,0.35)'}
/>
{/* Rung separator line (top border, skip very first) */}
{rungIdx > 0 && (
<line
x1={railX_left + RAIL_WIDTH / 2}
y1={rungY}
x2={railX_right - RAIL_WIDTH / 2}
y2={rungY}
stroke="rgba(0,0,0,0.07)"
strokeWidth={1}
/>
)}
{/* Rung number badge */}
<rect
x={8}
y={centerY - 10}
width={28}
height={20}
fill="var(--rung-number-bg)"
rx={3}
opacity={0.88}
/>
<text
x={22}
y={centerY + 1}
textAnchor="middle"
dominantBaseline="middle"
className="ladder-rung-number"
>
{rungIdx + 1}
</text>
{/* Small tick mark from rail to rung wire — left */}
<line
x1={railX_left + RAIL_WIDTH / 2 - 1} y1={centerY}
x2={railX_left + RAIL_WIDTH / 2 + 6} y2={centerY}
stroke="var(--wire-inactive)"
strokeWidth={WIRE_W}
strokeLinecap="round"
/>
{/* Transparent drop zone for whole rung area */}
<rect
x={railX_left}
y={rungY}
width={rungWidth}
height={rungH}
fill="transparent"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, rungIdx)}
style={{ pointerEvents: 'all' }}
/>
{/* ── Wires ── */}
{/* Wire: left rail → first element (or all the way to right rail if empty) */}
{rung.series.length === 0 ? (
<line
x1={railX_left + RAIL_WIDTH / 2 + 6} y1={centerY}
x2={railX_right - RAIL_WIDTH / 2} y2={centerY}
style={wireStyle(engineState.running)}
/>
) : (
<line
x1={railX_left + RAIL_WIDTH / 2 + 6} y1={centerY}
x2={getLeftX(0)} y2={centerY}
style={wireStyle(engineState.running)}
/>
)}
{/* Wires between consecutive elements */}
{rung.series.slice(0, -1).map((_, i) => {
const wireActive = isRungActive(rungIdx, i);
const x1 = getRightX(i);
const x2 = getLeftX(i + 1);
return (
<React.Fragment key={`wire-${i}`}>
<line
x1={x1} y1={centerY}
x2={x2} y2={centerY}
style={wireStyle(wireActive)}
/>
{/* Junction dot on right side of element */}
<circle cx={x1} cy={centerY} r={2.5} style={dotStyle(wireActive)} />
</React.Fragment>
);
})}
{/* Wire: last element → right rail */}
{rung.series.length > 0 && (() => {
const lastIdx = rung.series.length - 1;
const lastAct = isRungActive(rungIdx, lastIdx);
return (
<React.Fragment>
<line
x1={getRightX(lastIdx)} y1={centerY}
x2={railX_right - RAIL_WIDTH / 2} y2={centerY}
style={wireStyle(lastAct)}
/>
{/* Dot at junction with right rail */}
<circle
cx={getRightX(lastIdx)}
cy={centerY}
r={2.5}
style={dotStyle(lastAct)}
/>
</React.Fragment>
);
})()}
{/* ── Elements ── */}
{rung.series.map((element, elIdx) => {
const { cs, slots } = elMeta[elIdx];
const inputPowered = elIdx === 0
? engineState.running
: isRungActive(rungIdx, elIdx - 1);
if (element.type === 'branch') {
const branch = element as Branch;
const leftBusX = railX_left + cs * slotWidth + slotWidth / 2;
const rightBusX = railX_left + (cs + slots) * slotWidth + slotWidth / 2;
return (
<g key={`el-${elIdx}`}>
{renderBranch(
branch,
leftBusX,
rightBusX,
centerY,
rungIdx,
elIdx,
inputPowered,
)}
</g>
);
}
// ── Regular element ──
const elCenterX = railX_left + (cs + 1) * slotWidth;
const elH = getElHeight(element);
const { isActive, isEnergized } = getElementStates(element);
return (
<Element
key={`el-${elIdx}`}
element={element}
x={elCenterX - EL_HALF_W}
y={centerY - elH / 2}
width={EL_HALF_W * 2}
height={elH}
isActive={isActive}
isEnergized={isEnergized}
isRunning={engineState.running}
onClick={(e) => {
e.stopPropagation();
onElementClick(rungIdx, elIdx, element);
}}
onDragStart={(e) => {
e.dataTransfer.setData(
'application/json',
JSON.stringify({ fromRungIdx: rungIdx, fromElIdx: elIdx }),
);
e.dataTransfer.setData('text/plain', 'element');
}}
/>
);
})}
</g>
);
})}
</svg>
);
};