import React from 'react';
import type {
RungElement, Contact, Coil, Timer, Counter, LogicGate, MathElement, Branch,
OneShot, MoveElement, ScaleElement, NoOp, CompareElement
} from '../../engine/types';
interface ElementProps {
element: RungElement;
x: number;
y: number;
width: number;
height: number;
isActive?: boolean;
isEnergized?: boolean;
isRunning?: boolean;
isSelected?: boolean;
onClick: (e: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
}
// Wrapper to enable drag on SVG groups (SVG <g> doesn't natively support draggable)
const DraggableGroup: React.FC<{
className?: string;
onClick: (e: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
children: React.ReactNode;
}> = ({ className, onClick, onDragStart, children }) => {
const groupRef = React.useRef<SVGGElement>(null);
React.useEffect(() => {
const el = groupRef.current;
if (!el) return;
el.setAttribute('draggable', 'true');
}, []);
return (
<g
ref={groupRef}
className={className}
onClick={onClick}
onDragStart={onDragStart}
style={{ cursor: 'pointer' }}
>
{children}
</g>
);
};
export const Element: React.FC<ElementProps> = ({
element, x, y, width, height, isActive, isEnergized, isRunning, isSelected, onClick, onDragStart
}) => {
const plateActive = isActive || isEnergized || isRunning;
const plateClass = ['element-plate'];
if (plateActive) plateClass.push('active');
if (isSelected) plateClass.push('selected');
// Plate background with rounded corners and padding
const PAD = 4;
const plateX = x - PAD;
const plateY = y - PAD;
const plateW = width + PAD * 2;
const plateH = height + PAD * 2;
const cx = x + width / 2;
const cy = y + height / 2;
// Color shortcuts
const activeColor = 'var(--success)';
const inactiveColor = 'var(--text-primary)';
const symColor = (plateActive && (isActive || isEnergized)) ? activeColor : inactiveColor;
const labelColor = (plateActive && (isActive || isEnergized)) ? activeColor : 'var(--text-secondary)';
const plate = (
<rect
className="element-bg"
x={plateX} y={plateY} width={plateW} height={plateH}
rx={4}
/>
);
// Common label above the plate (address)
const labelAbove = (text: string, offset = -8) => (
<text
className="element-label-above"
x={cx} y={plateY + offset}
textAnchor="middle"
fontSize="9"
fill={labelColor}
>
{text}
</text>
);
// Common label below the plate (type mnemonic)
const labelBelow = (text: string) => (
<text
className="element-label"
x={cx} y={plateY + plateH + 12}
textAnchor="middle"
fontSize="9"
fill={labelColor}
>
{text}
</text>
);
switch (element.type) {
case 'contact': {
const contact = element as Contact;
const isNO = contact.contactType === 'NO';
// Contact bars at ±13px from center for clear visibility
const barOffset = 13;
const barX1 = cx - barOffset;
const barX2 = cx + barOffset;
const barY1 = cy - 11;
const barY2 = cy + 11;
// Edge type label
const edgeLabelMap: Record<string, string> = {
RISING: 'OSR', FALLING: 'OSF', CHANGE: 'OSC',
};
const edgeLabel = contact.edgeType ? edgeLabelMap[contact.edgeType] ?? '' : '';
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
{/* Left contact bar */}
<line
x1={barX1} y1={barY1} x2={barX1} y2={barY2}
stroke={symColor} strokeWidth={2.5} strokeLinecap="round"
/>
{/* Right contact bar */}
<line
x1={barX2} y1={barY1} x2={barX2} y2={barY2}
stroke={symColor} strokeWidth={2.5} strokeLinecap="round"
/>
{/* NC diagonal — top-left to bottom-right, only on Normally Closed */}
{!isNO && (
<line
x1={barX1} y1={barY1 + 2}
x2={barX2} y2={barY2 - 2}
stroke={symColor} strokeWidth={1.5} strokeLinecap="round"
/>
)}
{/* Address — between the bars, centered */}
<text
className="element-label-above"
x={cx} y={cy + 4}
textAnchor="middle"
fontSize="9"
fontWeight="700"
fill={symColor}
>
{contact.address}
</text>
{/* Edge type indicator above plate */}
{edgeLabel && (
<text
x={cx} y={plateY - 3}
textAnchor="middle"
fontSize="7.5"
fontWeight="700"
fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
[{edgeLabel}]
</text>
)}
{/* Type label below */}
{labelBelow(isNO ? 'XIC' : 'XIO')}
</DraggableGroup>
);
}
case 'coil': {
const coil = element as Coil;
const latchIndicator = coil.coilType === 'LATCH' ? 'L' : coil.coilType === 'UNLATCH' ? 'U' : '';
// Coil circle — slightly larger and cleaner
const r = Math.min(width, height) / 2 - 1;
const coilTypeLabel: Record<string, string> = {
OUT: 'OTE', LATCH: 'OTL', UNLATCH: 'OTU',
};
const coilLabel = coilTypeLabel[coil.coilType ?? 'OUT'] ?? coil.coilType ?? 'OTE';
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
{/* Coil circle */}
<circle
cx={cx} cy={cy}
r={r}
fill="none"
stroke={symColor}
strokeWidth={2.5}
/>
{/* Latch/Unlatch interior marker */}
{latchIndicator ? (
<text
x={cx} y={cy + 4}
textAnchor="middle"
fontSize="10"
fontWeight="900"
fill={symColor}
>
{latchIndicator}
</text>
) : (
/* Address label above for standard OTE */
labelAbove(coil.address, -3)
)}
{/* Address below for latch types */}
{latchIndicator && (
<text
className="element-label-above"
x={cx} y={plateY - 3}
textAnchor="middle"
fontSize="9"
fontWeight="700"
fill={labelColor}
>
{coil.address}
</text>
)}
{/* Type label below */}
{labelBelow(coilLabel)}
</DraggableGroup>
);
}
case 'timer': {
const timer = element as Timer;
const boxX = x + 1;
const boxY = y;
const boxW = width - 2;
const boxH = height;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
{/* Timer box — full height rect */}
<rect
x={boxX} y={boxY} width={boxW} height={boxH}
fill={plateActive && isActive ? 'rgba(26,138,60,0.07)' : 'rgba(0,0,0,0.02)'}
stroke={symColor}
strokeWidth={1.5}
rx={2}
/>
{/* Header divider line */}
<line
x1={boxX + 1} y1={boxY + 16}
x2={boxX + boxW - 1} y2={boxY + 16}
stroke={symColor} strokeWidth={0.75} opacity={0.4}
/>
{/* Timer type — bold header */}
<text
x={cx} y={boxY + 11}
textAnchor="middle"
fontSize="10"
fontWeight="800"
fill={symColor}
>
{timer.timerType}
</text>
{/* Instance ID */}
<text
x={cx} y={boxY + 26}
textAnchor="middle"
fontSize="8"
fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
{timer.instanceId}
</text>
{/* PRE (Preset) */}
<text
x={cx} y={boxY + 37}
textAnchor="middle"
fontSize="8"
fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
PRE: {(timer.preset / 1000).toFixed(1)}s
</text>
{/* ACC (Accumulated) */}
<text
x={cx} y={boxY + 48}
textAnchor="middle"
fontSize="8"
fontWeight={isActive ? '700' : '400'}
fill={isActive ? activeColor : labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
ACC: {(timer.accumulated / 1000).toFixed(1)}s
</text>
{/* DN bit indicator — small dot when done */}
{timer.accumulated >= timer.preset && timer.preset > 0 && (
<circle cx={boxX + boxW - 5} cy={boxY + 5} r={3}
fill={activeColor} opacity={0.9}
/>
)}
</DraggableGroup>
);
}
case 'counter': {
const counter = element as Counter;
const boxX = x + 1;
const boxY = y;
const boxW = width - 2;
const boxH = height;
const isDone = counter.current !== undefined && counter.preset !== undefined &&
(counter.counterType === 'CTD' ? counter.current <= 0 : counter.current >= counter.preset);
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect
x={boxX} y={boxY} width={boxW} height={boxH}
fill={plateActive && isActive ? 'rgba(26,138,60,0.07)' : 'rgba(0,0,0,0.02)'}
stroke={symColor}
strokeWidth={1.5}
rx={2}
/>
{/* Header divider */}
<line
x1={boxX + 1} y1={boxY + 16}
x2={boxX + boxW - 1} y2={boxY + 16}
stroke={symColor} strokeWidth={0.75} opacity={0.4}
/>
{/* Counter type */}
<text
x={cx} y={boxY + 11}
textAnchor="middle"
fontSize="10"
fontWeight="800"
fill={symColor}
>
{counter.counterType}
</text>
{/* Instance ID */}
<text
x={cx} y={boxY + 26}
textAnchor="middle"
fontSize="8"
fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
{counter.instanceId}
</text>
{/* PV */}
<text
x={cx} y={boxY + 37}
textAnchor="middle"
fontSize="8"
fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
PV: {counter.preset}
</text>
{/* ACC */}
<text
x={cx} y={boxY + 48}
textAnchor="middle"
fontSize="8"
fontWeight={isDone ? '700' : '400'}
fill={isDone ? activeColor : labelColor}
fontFamily="'SF Mono', 'Consolas', monospace"
>
ACC: {counter.current ?? 0}
</text>
{/* DN bit dot */}
{isDone && (
<circle cx={boxX + boxW - 5} cy={boxY + 5} r={3}
fill={activeColor} opacity={0.9}
/>
)}
</DraggableGroup>
);
}
case 'gate': {
const gate = element as LogicGate;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect
x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke={symColor} strokeWidth={1.5} rx={2}
/>
<text x={cx} y={cy + 4} textAnchor="middle" fontSize="9" fontWeight="800" fill={symColor}>
{gate.gateType}
</text>
{labelBelow(gate.outputAddress)}
</DraggableGroup>
);
}
case 'math': {
const math = element as MathElement;
const mnemonicMap: Record<string, string> = {
'+': 'ADD', '-': 'SUB', '*': 'MUL', '/': 'DIV',
};
const mnemonic = mnemonicMap[math.operator] ?? math.operator;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect
x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke={symColor} strokeWidth={1.5} rx={2}
/>
<text x={cx} y={cy - 1} textAnchor="middle" fontSize="10" fontWeight="800" fill={symColor}>
{mnemonic}
</text>
<text x={cx} y={cy + 11} textAnchor="middle" fontSize="7.5" fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace">
{math.outputAddress}
</text>
</DraggableGroup>
);
}
case 'branch': {
const branch = element as Branch;
const pathCount = branch.paths.length;
const pathSlotH = 40;
const branchHeight = pathCount * pathSlotH;
return (
<g className={plateClass.join(' ')} onClick={onClick}>
<rect
x={x - 4} y={y - 4}
width={width + 8} height={branchHeight + 8}
fill="rgba(0,0,0,0.02)"
stroke="rgba(0,0,0,0.12)"
strokeWidth={0.75}
rx={4}
strokeDasharray="5,3"
/>
{/* Left vertical bus bar */}
<line
x1={x} y1={y} x2={x} y2={y + branchHeight}
stroke="var(--wire-inactive)" strokeWidth={2.5} strokeLinecap="round"
/>
{/* Right vertical bus bar */}
<line
x1={x + width} y1={y} x2={x + width} y2={y + branchHeight}
stroke="var(--wire-inactive)" strokeWidth={2.5} strokeLinecap="round"
/>
{branch.paths.map((_, pathIdx) => {
const pathY = y + pathIdx * pathSlotH + pathSlotH / 2;
return (
<line
key={pathIdx}
x1={x} y1={pathY} x2={x + width} y2={pathY}
stroke="var(--wire-inactive)" strokeWidth={1}
strokeDasharray="4,3" opacity={0.45}
/>
);
})}
{/* Logic badge */}
<rect
x={cx - 14} y={y - 15}
width={28} height={12}
fill="var(--text-secondary)" rx={6} opacity={0.8}
/>
<text
x={cx} y={y - 7}
textAnchor="middle" fontSize="7.5" fontWeight="700" fill="#fff"
fontFamily="'Segoe UI', system-ui, sans-serif"
>
{branch.logic}
</text>
</g>
);
}
case 'oneshot': {
const os = element as OneShot;
const barOffset = 13;
const barX1 = cx - barOffset;
const barX2 = cx + barOffset;
const barY1 = cy - 11;
const barY2 = cy + 11;
const isRising = os.edgeType === 'RISING';
// Step marker geometry
const smTop = cy - 5;
const smBot = cy + 5;
const smMid = cx;
const smL = cx - 7;
const smR = cx + 7;
const stepPoints = isRising
? `${smL},${smBot} ${smL},${smTop} ${smMid},${smTop} ${smMid},${smBot} ${smR},${smBot}`
: `${smL},${smTop} ${smL},${smBot} ${smMid},${smBot} ${smMid},${smTop} ${smR},${smTop}`;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
{/* Contact bars */}
<line x1={barX1} y1={barY1} x2={barX1} y2={barY2}
stroke={symColor} strokeWidth={2.5} strokeLinecap="round" />
<line x1={barX2} y1={barY1} x2={barX2} y2={barY2}
stroke={symColor} strokeWidth={2.5} strokeLinecap="round" />
{/* Edge step marker */}
<polyline
points={stepPoints}
fill="none" stroke={symColor} strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round"
/>
{/* Address above */}
<text x={cx} y={plateY - 3} textAnchor="middle" fontSize="9" fontWeight="700" fill={labelColor}>
{os.address}
</text>
{labelBelow(isRising ? 'OSR' : 'OSF')}
</DraggableGroup>
);
}
case 'move': {
const move = element as MoveElement;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke={symColor} strokeWidth={1.5} rx={2} />
<text x={cx} y={cy - 1} textAnchor="middle" fontSize="10" fontWeight="800" fill={symColor}>
MOV
</text>
<text x={cx} y={cy + 11} textAnchor="middle" fontSize="7.5" fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace">
{move.source}→{move.destination}
</text>
</DraggableGroup>
);
}
case 'scale': {
const scale = element as ScaleElement;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke={symColor} strokeWidth={1.5} rx={2} />
<text x={cx} y={cy - 1} textAnchor="middle" fontSize="10" fontWeight="800" fill={symColor}>
SCL
</text>
<text x={cx} y={cy + 11} textAnchor="middle" fontSize="7.5" fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace">
{scale.inputAddress}
</text>
</DraggableGroup>
);
}
case 'compare': {
const cmp = element as CompareElement;
const mnemonicMap: Record<string, string> = {
'==': 'EQU', '!=': 'NEQ', '>': 'GRT', '<': 'LES', '>=': 'GEQ', '<=': 'LEQ',
};
const mnemonic = mnemonicMap[cmp.op] ?? 'CMP';
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke={symColor} strokeWidth={1.5} rx={2} />
<text x={cx} y={cy - 1} textAnchor="middle" fontSize="10" fontWeight="800" fill={symColor}>
{mnemonic}
</text>
<text x={cx} y={cy + 11} textAnchor="middle" fontSize="7.5" fill={labelColor}
fontFamily="'SF Mono', 'Consolas', monospace">
{cmp.inputA} {cmp.op} {cmp.inputB}
</text>
</DraggableGroup>
);
}
case 'noop': {
const noop = element as NoOp;
return (
<DraggableGroup className={plateClass.join(' ')} onClick={onClick} onDragStart={onDragStart}>
{plate}
<rect x={x + 2} y={y} width={width - 4} height={height}
fill="none" stroke="var(--text-muted)"
strokeWidth={1} strokeDasharray="3,2" rx={2} />
<text x={cx} y={cy + 4} textAnchor="middle" fontSize="8" fill="var(--text-muted)">
{noop.comment ? noop.comment : 'NOP'}
</text>
</DraggableGroup>
);
}
default:
return null;
}
};