From dc9c2773de2ecd6bd1a90f7f5d6c8cc3010689d9 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Thu, 19 Feb 2026 22:56:11 -0500 Subject: [PATCH] Sprint 3: unify React canvas interactions and ELK boundary metadata --- frontend-react/src/App.tsx | 2 +- frontend-react/src/components/CanvasArea.tsx | 847 ++++++++++++++++++- frontend-react/src/styles.css | 70 ++ src/compile.js | 9 +- src/layout-elk.js | 62 ++ src/layout.js | 55 +- tests/api-contract.test.js | 3 + tests/compile.test.js | 41 + 8 files changed, 1050 insertions(+), 39 deletions(-) create mode 100644 src/layout-elk.js diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx index 2713708..ef54c8b 100644 --- a/frontend-react/src/App.tsx +++ b/frontend-react/src/App.tsx @@ -184,7 +184,7 @@ export function App() { }) } /> - + diff --git a/frontend-react/src/components/CanvasArea.tsx b/frontend-react/src/components/CanvasArea.tsx index 7c9ab67..d0a3848 100644 --- a/frontend-react/src/components/CanvasArea.tsx +++ b/frontend-react/src/components/CanvasArea.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useSyncExternalStore } from "react"; -import type { PointerEvent as ReactPointerEvent } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; import { createSchemetaStore } from "../state/store.js"; import type { SchemetaInstance, SchemetaModel, SchemetaStore } from "../state/store.js"; @@ -11,6 +11,8 @@ const AUTO_X_START = 60; const AUTO_Y_START = 60; const AUTO_X_GAP = 210; const AUTO_Y_GAP = 150; +const NUDGE_STEP = 10; +const NUDGE_STEP_FAST = 20; const BOOTSTRAP_MODEL: SchemetaModel = { symbols: { @@ -67,13 +69,35 @@ type NodePlacement = { rightPins: SymbolPin[]; }; +type CanvasPoint = { x: number; y: number }; + +type CanvasWireSegment = { + a: CanvasPoint; + b: CanvasPoint; +}; + +type CanvasWire = { + id: string; + label: string; + netClass: string | null; + segments: CanvasWireSegment[]; + labelPoint: CanvasPoint | null; + highlight: boolean; + source: "routed" | "fallback"; +}; + type DragState = { - ref: string; + refs: string[]; pointerId: number; startClientX: number; startClientY: number; - startX: number; - startY: number; + starts: Map; +}; + +type BoxSelectState = { + pointerId: number; + start: CanvasPoint; + current: CanvasPoint; }; const fallbackStore = createSchemetaStore({ @@ -99,6 +123,157 @@ function toDimension(value: unknown, fallback: number, min = 80, max = 320) { return Math.max(min, Math.min(max, Math.round(parsed))); } +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null) { + return null; + } + return value as Record; +} + +function asPoint(value: unknown): CanvasPoint | null { + if (Array.isArray(value) && value.length >= 2) { + const x = Number(value[0]); + const y = Number(value[1]); + if (Number.isFinite(x) && Number.isFinite(y)) { + return { x, y }; + } + } + + const record = asRecord(value); + if (!record) { + return null; + } + + const x = Number(record.x); + const y = Number(record.y); + if (Number.isFinite(x) && Number.isFinite(y)) { + return { x, y }; + } + + return null; +} + +function rotatePoint(point: CanvasPoint, center: CanvasPoint, rotationDeg: number): CanvasPoint { + if (!Number.isFinite(rotationDeg) || rotationDeg === 0) { + return point; + } + + const radians = (rotationDeg * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const dx = point.x - center.x; + const dy = point.y - center.y; + return { + x: center.x + dx * cos - dy * sin, + y: center.y + dx * sin + dy * cos + }; +} + +function pointsToSegments(points: CanvasPoint[]): CanvasWireSegment[] { + const segments: CanvasWireSegment[] = []; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1]; + const b = points[index]; + if (a.x === b.x && a.y === b.y) { + continue; + } + segments.push({ a, b }); + } + return segments; +} + +function asSegment(value: unknown): CanvasWireSegment | null { + const record = asRecord(value); + if (!record) { + return null; + } + + const a = asPoint(record.a ?? record.start ?? record.from); + const b = asPoint(record.b ?? record.end ?? record.to); + if (a && b) { + return { a, b }; + } + + const x1 = Number(record.x1); + const y1 = Number(record.y1); + const x2 = Number(record.x2); + const y2 = Number(record.y2); + if (Number.isFinite(x1) && Number.isFinite(y1) && Number.isFinite(x2) && Number.isFinite(y2)) { + return { a: { x: x1, y: y1 }, b: { x: x2, y: y2 } }; + } + + return null; +} + +function getSegmentsFromUnknown(value: unknown): CanvasWireSegment[] { + if (!Array.isArray(value)) { + return []; + } + + const points = value.map((entry) => asPoint(entry)).filter((entry): entry is CanvasPoint => entry !== null); + if (points.length >= 2 && points.length === value.length) { + return pointsToSegments(points); + } + + const segments: CanvasWireSegment[] = []; + value.forEach((entry) => { + const segment = asSegment(entry); + if (segment) { + segments.push(segment); + return; + } + + const nested = asRecord(entry); + if (!nested) { + return; + } + + const nestedSegments = [ + ...getSegmentsFromUnknown(nested.segments), + ...getSegmentsFromUnknown(nested.path), + ...getSegmentsFromUnknown(nested.polyline), + ...getSegmentsFromUnknown(nested.points) + ]; + segments.push(...nestedSegments); + }); + + return segments; +} + +function segmentLengthSquared(segment: CanvasWireSegment): number { + const dx = segment.b.x - segment.a.x; + const dy = segment.b.y - segment.a.y; + return dx * dx + dy * dy; +} + +function segmentMidpoint(segment: CanvasWireSegment): CanvasPoint { + return { + x: (segment.a.x + segment.b.x) / 2, + y: (segment.a.y + segment.b.y) / 2 + }; +} + +function pickLabelPoint(segments: CanvasWireSegment[], preferred: CanvasPoint | null): CanvasPoint | null { + if (preferred) { + return preferred; + } + if (segments.length === 0) { + return null; + } + + let longest = segments[0]; + let bestLength = segmentLengthSquared(segments[0]); + for (let index = 1; index < segments.length; index += 1) { + const candidateLength = segmentLengthSquared(segments[index]); + if (candidateLength > bestLength) { + bestLength = candidateLength; + longest = segments[index]; + } + } + + return segmentMidpoint(longest); +} + function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) { if (!instance.symbol || !symbols || typeof symbols !== "object") { return null; @@ -126,6 +301,177 @@ function getNodePlacement(instance: SchemetaInstance, index: number, symbols: Sc return { x, y, width, height, rotation, locked, leftPins, rightPins }; } +function toNetIdentity(netRecord: Record, index: number) { + const named = typeof netRecord.name === "string" && netRecord.name.trim().length > 0 ? netRecord.name.trim() : null; + return { + id: named ?? `net-${index + 1}`, + label: named ?? `Net ${index + 1}` + }; +} + +function getPinAnchor( + placement: NodePlacement, + instance: SchemetaInstance, + symbols: SchemetaModel["symbols"], + pinName: string | null +): CanvasPoint { + const symbol = getSymbolNode(symbols, instance) as { pins?: SymbolPin[] } | null; + const pins = Array.isArray(symbol?.pins) ? symbol.pins : []; + const normalizedPin = pinName?.trim() ?? ""; + const pin = pins.find((candidate) => { + const candidateName = typeof candidate?.name === "string" ? candidate.name : ""; + const candidateNumber = typeof candidate?.number === "string" ? candidate.number : ""; + return normalizedPin.length > 0 && (candidateName === normalizedPin || candidateNumber === normalizedPin); + }); + + const offset = Math.max(0, Math.min(placement.height, toFinite(pin?.offset, placement.height * 0.5))); + let anchor: CanvasPoint; + switch (pin?.side) { + case "left": + anchor = { x: placement.x, y: placement.y + offset }; + break; + case "top": + anchor = { x: placement.x + placement.width * 0.5, y: placement.y }; + break; + case "bottom": + anchor = { x: placement.x + placement.width * 0.5, y: placement.y + placement.height }; + break; + case "right": + default: + anchor = { x: placement.x + placement.width, y: placement.y + offset }; + break; + } + + const center = { x: placement.x + placement.width / 2, y: placement.y + placement.height / 2 }; + return rotatePoint(anchor, center, placement.rotation); +} + +function extractRoutedWires(compileResult: unknown, selectedNet: string | null): CanvasWire[] { + const compile = asRecord(compileResult); + if (!compile) { + return []; + } + + const layout = asRecord(compile.layout); + const topology = asRecord(compile.topology); + const candidates: unknown[] = [layout?.routed, layout?.routes, compile.routed, compile.routes, topology?.routed]; + + for (const candidate of candidates) { + if (!Array.isArray(candidate) || candidate.length === 0) { + continue; + } + + const parsed: CanvasWire[] = []; + candidate.forEach((entry, index) => { + const route = asRecord(entry); + if (!route) { + return; + } + + const netRecord = asRecord(route.net); + const netNameRaw = + (typeof netRecord?.name === "string" && netRecord.name.trim().length > 0 ? netRecord.name.trim() : null) ?? + (typeof route.net_name === "string" && route.net_name.trim().length > 0 ? route.net_name.trim() : null) ?? + (typeof route.net === "string" && route.net.trim().length > 0 ? route.net.trim() : null) ?? + (typeof route.name === "string" && route.name.trim().length > 0 ? route.name.trim() : null); + + const id = netNameRaw ?? `net-${index + 1}`; + const label = netNameRaw ?? `Net ${index + 1}`; + const netClass = + (typeof netRecord?.class === "string" && netRecord.class.trim().length > 0 ? netRecord.class.trim() : null) ?? + (typeof route.class === "string" && route.class.trim().length > 0 ? route.class.trim() : null); + + const segments = [ + ...getSegmentsFromUnknown(route.routes), + ...getSegmentsFromUnknown(route.segments), + ...getSegmentsFromUnknown(route.path), + ...getSegmentsFromUnknown(route.polyline), + ...getSegmentsFromUnknown(route.points) + ]; + if (segments.length === 0) { + return; + } + + const preferredPoint = + asPoint(Array.isArray(route.labelPoints) ? route.labelPoints[0] : null) ?? + asPoint(Array.isArray(route.tiePoints) ? route.tiePoints[0] : null) ?? + asPoint(Array.isArray(route.junctionPoints) ? route.junctionPoints[0] : null); + + parsed.push({ + id, + label, + netClass, + segments, + labelPoint: pickLabelPoint(segments, preferredPoint), + highlight: selectedNet !== null && selectedNet === id, + source: "routed" + }); + }); + + if (parsed.length > 0) { + return parsed; + } + } + + return []; +} + +function buildFallbackWires( + nets: Record[], + placements: Map, + instancesByRef: Map, + symbols: SchemetaModel["symbols"], + selectedNet: string | null +): CanvasWire[] { + const wires: CanvasWire[] = []; + + nets.forEach((net, index) => { + const { id, label } = toNetIdentity(net, index); + const netClass = typeof net.class === "string" && net.class.trim().length > 0 ? net.class.trim() : null; + const nodesRaw = Array.isArray(net.nodes) ? net.nodes : []; + + const points = nodesRaw + .map((node) => { + const endpoint = asRecord(node); + if (!endpoint || typeof endpoint.ref !== "string") { + return null; + } + + const placement = placements.get(endpoint.ref); + const instance = instancesByRef.get(endpoint.ref); + if (!placement || !instance) { + return null; + } + + const pinName = typeof endpoint.pin === "string" && endpoint.pin.trim().length > 0 ? endpoint.pin : null; + return getPinAnchor(placement, instance, symbols, pinName); + }) + .filter((value): value is CanvasPoint => value !== null); + + if (points.length < 2) { + return; + } + + const root = points[0]; + const segments = points.slice(1).map((point) => ({ a: root, b: point })); + if (segments.length === 0) { + return; + } + + wires.push({ + id, + label, + netClass, + segments, + labelPoint: pickLabelPoint(segments, null), + highlight: selectedNet !== null && selectedNet === id, + source: "fallback" + }); + }); + + return wires; +} + function formatZoom(scale: number) { return `${Math.round(scale * 100)}%`; } @@ -134,13 +480,77 @@ type CanvasAreaProps = { store?: SchemetaStore; }; +function shouldIgnoreKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || target.isContentEditable; +} + +function toggleRefSelection(selectedRefs: string[], ref: string): string[] { + const set = new Set(selectedRefs); + if (set.has(ref)) { + set.delete(ref); + } else { + set.add(ref); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); +} + +function normalizeRotation(value: unknown): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + const normalized = ((Math.round(numeric / 90) * 90) % 360 + 360) % 360; + return normalized; +} + +function removeRefsFromModel(model: SchemetaModel, selectedRefs: string[]): SchemetaModel { + if (!Array.isArray(model.instances) || selectedRefs.length === 0) { + return model; + } + + const refSet = new Set(selectedRefs); + const instances = model.instances.filter((instance) => !refSet.has(instance.ref)); + + const nets = Array.isArray(model.nets) + ? model.nets + .map((net) => { + if (!Array.isArray(net.nodes)) { + return net; + } + const nodes = net.nodes.filter((node) => { + const endpoint = asRecord(node); + return !(endpoint && typeof endpoint.ref === "string" && refSet.has(endpoint.ref)); + }); + return { + ...net, + nodes + }; + }) + .filter((net) => Array.isArray(net.nodes) && net.nodes.length > 1) + : model.nets; + + return { + ...model, + instances, + nets + }; +} + export function CanvasArea({ store: providedStore }: CanvasAreaProps) { const store = providedStore ?? fallbackStore; const state = useStoreState(store); const [drag, setDrag] = useState(null); + const [boxSelect, setBoxSelect] = useState(null); + const instances = state.model?.instances ?? []; const symbols = state.model?.symbols ?? {}; - const selectedRef = state.selection.selectedRefs[0] ?? null; + const selectedRefs = state.selection.selectedRefs; + const selectedSet = useMemo(() => new Set(selectedRefs), [selectedRefs]); + const selectedNet = state.selection.selectedNet; const placements = useMemo(() => { const byRef = new Map(); @@ -150,12 +560,34 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) { return byRef; }, [instances, symbols]); - function setSelection(ref: string | null) { + const instancesByRef = useMemo(() => { + return new Map(instances.map((instance) => [instance.ref, instance])); + }, [instances]); + + const modelNets = useMemo( + () => + (state.model?.nets ?? []).filter((item): item is Record => typeof item === "object" && item !== null), + [state.model?.nets] + ); + + const routedWires = useMemo(() => extractRoutedWires(state.compileResult, selectedNet), [state.compileResult, selectedNet]); + + const fallbackWires = useMemo( + () => buildFallbackWires(modelNets, placements, instancesByRef, symbols, selectedNet), + [instancesByRef, modelNets, placements, selectedNet, symbols] + ); + + const hasRoutedWires = routedWires.length > 0; + const visibleWires = hasRoutedWires ? routedWires : fallbackWires; + const hasSelectedNet = selectedNet !== null; + + function clearSelection() { store.actions.setSelection({ - selectedRefs: ref ? [ref] : [], + selectedRefs: [], selectedNet: null, selectedPin: null }); + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); } function updateScale(nextScale: number) { @@ -163,56 +595,339 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) { store.actions.setViewport({ scale: clamped }); } - function handleCanvasPointerDown(event: ReactPointerEvent) { - if (event.target === event.currentTarget) { - setSelection(null); - } + function eventToCanvasPoint(event: ReactPointerEvent): CanvasPoint { + const surface = event.currentTarget; + const rect = surface.getBoundingClientRect(); + const localX = event.clientX - rect.left + surface.scrollLeft; + const localY = event.clientY - rect.top + surface.scrollTop; + + return { + x: (localX - state.viewport.panX) / state.viewport.scale, + y: (localY - state.viewport.panY) / state.viewport.scale + }; } - function handleNodePointerDown(event: ReactPointerEvent, ref: string, placement: NodePlacement) { - if (event.button !== 0) { + function beginDrag(refs: string[], event: ReactPointerEvent) { + const starts = new Map(); + refs.forEach((ref) => { + const placement = placements.get(ref); + if (!placement || placement.locked) { + return; + } + starts.set(ref, { x: placement.x, y: placement.y }); + }); + + if (starts.size === 0) { return; } - setSelection(ref); - if (placement.locked) { - return; - } + store.actions.beginTransaction(starts.size > 1 ? "drag components" : "drag component"); - event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setDrag({ - ref, + refs: Array.from(starts.keys()), pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, - startX: placement.x, - startY: placement.y + starts }); } - function handleNodePointerMove(event: ReactPointerEvent, ref: string) { - if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) { + function handleCanvasPointerDown(event: ReactPointerEvent) { + if (event.button !== 0 || event.target !== event.currentTarget) { + return; + } + + event.currentTarget.focus(); + + const additive = event.shiftKey || event.metaKey || event.ctrlKey; + if (!additive) { + clearSelection(); + } + + const point = eventToCanvasPoint(event); + event.currentTarget.setPointerCapture(event.pointerId); + setBoxSelect({ + pointerId: event.pointerId, + start: point, + current: point + }); + } + + function handleCanvasPointerMove(event: ReactPointerEvent) { + if (!boxSelect || boxSelect.pointerId !== event.pointerId) { return; } event.preventDefault(); - const dx = (event.clientX - drag.startClientX) / state.viewport.scale; - const dy = (event.clientY - drag.startClientY) / state.viewport.scale; - store.actions.moveComponent(ref, { x: drag.startX + dx, y: drag.startY + dy }); + const point = eventToCanvasPoint(event); + setBoxSelect((prev) => { + if (!prev) { + return null; + } + return { + ...prev, + current: point + }; + }); } - function handleNodePointerUpOrCancel(event: ReactPointerEvent, ref: string) { - if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) { + function handleCanvasPointerUpOrCancel(event: ReactPointerEvent) { + if (!boxSelect || boxSelect.pointerId !== event.pointerId) { return; } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } - setDrag(null); + + const additive = event.shiftKey || event.metaKey || event.ctrlKey; + const minX = Math.min(boxSelect.start.x, boxSelect.current.x); + const minY = Math.min(boxSelect.start.y, boxSelect.current.y); + const maxX = Math.max(boxSelect.start.x, boxSelect.current.x); + const maxY = Math.max(boxSelect.start.y, boxSelect.current.y); + const width = maxX - minX; + const height = maxY - minY; + + const nextSelected = + width < 4 && height < 4 + ? [] + : instances + .map((instance, index) => ({ ref: instance.ref, placement: placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols) })) + .filter((entry) => { + const nodeMinX = entry.placement.x; + const nodeMinY = entry.placement.y; + const nodeMaxX = entry.placement.x + entry.placement.width; + const nodeMaxY = entry.placement.y + entry.placement.height; + return !(nodeMaxX < minX || nodeMinX > maxX || nodeMaxY < minY || nodeMinY > maxY); + }) + .map((entry) => entry.ref) + .sort((a, b) => a.localeCompare(b)); + + if (nextSelected.length > 0 || !additive) { + const merged = additive ? Array.from(new Set([...state.selection.selectedRefs, ...nextSelected])) : nextSelected; + store.actions.setSelection({ + selectedRefs: merged.sort((a, b) => a.localeCompare(b)), + selectedNet: null, + selectedPin: null + }); + if (merged.length > 0) { + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); + } + } + + setBoxSelect(null); } + function handleNodePointerDown(event: ReactPointerEvent, ref: string) { + if (event.button !== 0) { + return; + } + + event.stopPropagation(); + event.currentTarget.focus(); + + const additive = event.shiftKey || event.metaKey || event.ctrlKey; + if (additive) { + store.actions.setSelection({ + selectedRefs: toggleRefSelection(state.selection.selectedRefs, ref), + selectedNet: null, + selectedPin: null + }); + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); + return; + } + + const isAlreadySelected = selectedSet.has(ref); + const dragCandidates = isAlreadySelected && selectedRefs.length > 1 ? selectedRefs : [ref]; + + if (!isAlreadySelected || selectedRefs.length !== dragCandidates.length) { + store.actions.setSelection({ + selectedRefs: dragCandidates, + selectedNet: null, + selectedPin: null + }); + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); + } + + beginDrag(dragCandidates, event); + } + + function handleNodePointerMove(event: ReactPointerEvent) { + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + const dx = (event.clientX - drag.startClientX) / state.viewport.scale; + const dy = (event.clientY - drag.startClientY) / state.viewport.scale; + + drag.refs.forEach((ref) => { + const origin = drag.starts.get(ref); + if (!origin) { + return; + } + store.actions.moveComponent(ref, { x: origin.x + dx, y: origin.y + dy }); + }); + } + + function handleNodePointerUpOrCancel(event: ReactPointerEvent) { + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + setDrag(null); + store.actions.commitTransaction(); + } + + function rotateSelected() { + if (selectedRefs.length === 0) { + return; + } + + const refs = selectedRefs.filter((ref) => { + const placement = placements.get(ref); + return Boolean(placement && !placement.locked); + }); + + if (refs.length === 0) { + return; + } + + store.actions.beginTransaction(refs.length > 1 ? "rotate components" : "rotate component"); + refs.forEach((ref) => { + const instance = instancesByRef.get(ref); + const current = normalizeRotation(instance?.placement?.rotation ?? 0); + store.actions.moveComponent(ref, { rotation: normalizeRotation(current + 90) }); + }); + store.actions.commitTransaction(); + } + + function nudgeSelected(dx: number, dy: number) { + if (selectedRefs.length === 0) { + return; + } + + const refs = selectedRefs.filter((ref) => { + const placement = placements.get(ref); + return Boolean(placement && !placement.locked); + }); + + if (refs.length === 0) { + return; + } + + store.actions.beginTransaction(refs.length > 1 ? "nudge components" : "nudge component"); + refs.forEach((ref) => { + const placement = placements.get(ref); + if (!placement) { + return; + } + store.actions.moveComponent(ref, { x: placement.x + dx, y: placement.y + dy }); + }); + store.actions.commitTransaction(); + } + + function deleteSelected() { + if (!state.model || selectedRefs.length === 0) { + return; + } + + const nextModel = removeRefsFromModel(state.model, selectedRefs); + store.actions.beginTransaction(selectedRefs.length > 1 ? "delete components" : "delete component"); + store.actions.setModel(nextModel); + clearSelection(); + store.actions.commitTransaction(); + } + + function handleCanvasKeyDown(event: ReactKeyboardEvent) { + if (shouldIgnoreKeyboardTarget(event.target)) { + return; + } + + const hasCommand = event.ctrlKey || event.metaKey; + if (hasCommand && event.key.toLowerCase() === "a") { + event.preventDefault(); + store.actions.setSelection({ + selectedRefs: instances.map((instance) => instance.ref).sort((a, b) => a.localeCompare(b)), + selectedNet: null, + selectedPin: null + }); + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + clearSelection(); + return; + } + + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + deleteSelected(); + return; + } + + if (event.code === "Space") { + event.preventDefault(); + rotateSelected(); + return; + } + + const step = event.shiftKey ? NUDGE_STEP_FAST : NUDGE_STEP; + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + nudgeSelected(0, -step); + break; + case "ArrowDown": + event.preventDefault(); + nudgeSelected(0, step); + break; + case "ArrowLeft": + event.preventDefault(); + nudgeSelected(-step, 0); + break; + case "ArrowRight": + event.preventDefault(); + nudgeSelected(step, 0); + break; + default: + break; + } + } + + function selectNet(netId: string | null) { + store.actions.setSelection({ + selectedRefs: [], + selectedNet: netId, + selectedPin: null + }); + store.actions.setUiFlags({ isolateComponent: false, isolateNet: false }); + } + + const selectionBoxStyle = useMemo(() => { + if (!boxSelect) { + return null; + } + + const left = Math.min(boxSelect.start.x, boxSelect.current.x); + const top = Math.min(boxSelect.start.y, boxSelect.current.y); + const width = Math.max(1, Math.abs(boxSelect.current.x - boxSelect.start.x)); + const height = Math.max(1, Math.abs(boxSelect.current.y - boxSelect.start.y)); + return { + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px` + }; + }, [boxSelect]); + return (
@@ -244,16 +959,75 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
-
+
+ + + {state.uiFlags.showLabels + ? visibleWires.map((wire) => { + if (!wire.labelPoint) { + return null; + } + const isDimmed = hasSelectedNet && !wire.highlight; + return ( + + ); + }) + : null} + + {selectionBoxStyle ?
: null} + {instances.map((instance, index) => { const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols); - const isSelected = selectedRef === instance.ref; + const isSelected = selectedSet.has(instance.ref); const valueLabel = typeof instance.properties?.value === "string" && instance.properties.value.length > 0 ? instance.properties.value @@ -262,12 +1036,13 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) { return (
handleNodePointerDown(event, instance.ref, placement)} - onPointerMove={(event) => handleNodePointerMove(event, instance.ref)} - onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)} - onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)} + onPointerDown={(event) => handleNodePointerDown(event, instance.ref)} + onPointerMove={handleNodePointerMove} + onPointerUp={handleNodePointerUpOrCancel} + onPointerCancel={handleNodePointerUpOrCancel} + tabIndex={-1} >
diff --git a/frontend-react/src/styles.css b/frontend-react/src/styles.css index d517402..2130de4 100644 --- a/frontend-react/src/styles.css +++ b/frontend-react/src/styles.css @@ -242,6 +242,19 @@ body { background-size: 28px 28px; } +.canvas__surface:focus-visible { + outline: 2px solid #0a79c2; + outline-offset: -2px; +} + +.canvas__selection-box { + position: absolute; + border: 1px solid #0a79c2; + background: rgba(10, 121, 194, 0.14); + pointer-events: none; + z-index: 2; +} + .canvas__viewport { position: relative; width: 2200px; @@ -249,6 +262,63 @@ body { transform-origin: 0 0; } +.canvas-wires { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: auto; +} + +.canvas-wire-segment { + stroke: #5a6e7f; + stroke-width: 2.2px; + stroke-linecap: round; + opacity: 0.9; + pointer-events: stroke; + cursor: pointer; +} + +.canvas-wire-segment.is-highlight { + stroke: #0b78bf; + stroke-width: 3px; + opacity: 1; +} + +.canvas-wire-segment.is-dimmed { + opacity: 0.2; +} + +.canvas-wire-label { + position: absolute; + transform: translate(-50%, calc(-100% - 6px)); + padding: 0.1rem 0.3rem; + border: 1px solid rgba(144, 158, 171, 0.8); + border-radius: 4px; + background: rgba(255, 255, 255, 0.95); + color: #243f53; + font-size: 0.66rem; + line-height: 1.1; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: auto; + cursor: pointer; +} + +button.canvas-wire-label { + font: inherit; +} + +.canvas-wire-label.is-highlight { + border-color: #0b78bf; + color: #0d4f7b; + font-weight: 600; +} + +.canvas-wire-label.is-dimmed { + opacity: 0.35; +} + .canvas-node { position: absolute; user-select: none; diff --git a/src/compile.js b/src/compile.js index a43597a..629f0c9 100644 --- a/src/compile.js +++ b/src/compile.js @@ -1,5 +1,5 @@ import { analyzeModel } from "./analyze.js"; -import { layoutAndRoute } from "./layout.js"; +import { DEFAULT_LAYOUT_ENGINE, layoutAndRoute, requestedLayoutEngine } from "./layout.js"; import { renderSvgFromLayout } from "./render.js"; import { validateModel } from "./validate.js"; @@ -239,6 +239,7 @@ function annotateIssues(issues, prefix) { export function compile(payload, options = {}) { const validated = validateModel(payload, options); + const layoutEngineRequested = requestedLayoutEngine(options); if (!validated.model) { const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E"); @@ -266,6 +267,9 @@ export function compile(payload, options = {}) { bus_groups: [], focus_map: {}, render_mode_used: options.render_mode ?? "schematic_stub", + layout_engine_requested: layoutEngineRequested, + layout_engine_used: DEFAULT_LAYOUT_ENGINE, + layout_warnings: [], svg: "" }; } @@ -300,6 +304,9 @@ export function compile(payload, options = {}) { layout_metrics: layout.metrics, bus_groups: layout.bus_groups, render_mode_used: layout.render_mode_used, + layout_engine_requested: layout.layout_engine_requested ?? layoutEngineRequested, + layout_engine_used: layout.layout_engine_used ?? DEFAULT_LAYOUT_ENGINE, + layout_warnings: Array.isArray(layout.layout_warnings) ? layout.layout_warnings : [], svg }; } diff --git a/src/layout-elk.js b/src/layout-elk.js new file mode 100644 index 0000000..c24c141 --- /dev/null +++ b/src/layout-elk.js @@ -0,0 +1,62 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const DEFAULT_ELK_MODULE = "elkjs/lib/elk.bundled.js"; +const runtimeCache = new Map(); + +function normalizeElkExport(mod) { + if (!mod) { + return null; + } + if (typeof mod === "function") { + return mod; + } + if (typeof mod.default === "function") { + return mod.default; + } + if (typeof mod.ELK === "function") { + return mod.ELK; + } + if (mod.default && typeof mod.default.ELK === "function") { + return mod.default.ELK; + } + return null; +} + +export function resolveElkRuntime(moduleId = DEFAULT_ELK_MODULE) { + const key = String(moduleId || DEFAULT_ELK_MODULE); + if (runtimeCache.has(key)) { + return runtimeCache.get(key); + } + + let state; + try { + const loaded = require(key); + const ElkCtor = normalizeElkExport(loaded); + if (!ElkCtor) { + state = { + ok: false, + module: key, + reason: "invalid_export", + message: `ELK module "${key}" loaded but did not expose a usable constructor.` + }; + } else { + state = { + ok: true, + module: key, + ElkCtor + }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + state = { + ok: false, + module: key, + reason: "module_load_failed", + message: `ELK module "${key}" unavailable: ${message}` + }; + } + + runtimeCache.set(key, state); + return state; +} diff --git a/src/layout.js b/src/layout.js index 8d2f181..a4bfd96 100644 --- a/src/layout.js +++ b/src/layout.js @@ -1,3 +1,5 @@ +import { resolveElkRuntime } from "./layout-elk.js"; + const GRID = 20; const MARGIN_X = 140; const MARGIN_Y = 140; @@ -19,6 +21,7 @@ const NET_CLASS_PRIORITY = { const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]); const DEFAULT_RENDER_MODE = "schematic_stub"; +export const DEFAULT_LAYOUT_ENGINE = "schemeta-v2"; const ROTATION_STEPS = [0, 90, 180, 270]; const MIN_CHANNEL_SPACING_STEPS = 3; const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"]; @@ -2389,7 +2392,15 @@ export function applyLayoutToModel(model, options = {}) { return working; } -export function layoutAndRoute(model, options = {}) { +export function requestedLayoutEngine(options = {}) { + const explicitEngine = typeof options.layout_engine === "string" ? options.layout_engine.trim().toLowerCase() : ""; + if (explicitEngine === "elk" || options.use_elk_layout === true) { + return "elk"; + } + return DEFAULT_LAYOUT_ENGINE; +} + +function layoutAndRouteNative(model, options = {}) { const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE; const respectLocks = options.respect_locks ?? true; const autoRotate = options.auto_rotate ?? true; @@ -2414,6 +2425,48 @@ export function layoutAndRoute(model, options = {}) { }; } +export function layoutAndRoute(model, options = {}) { + const requestedEngine = requestedLayoutEngine(options); + const nativeLayout = layoutAndRouteNative(model, options); + + if (requestedEngine !== "elk") { + return { + ...nativeLayout, + layout_engine_requested: requestedEngine, + layout_engine_used: DEFAULT_LAYOUT_ENGINE, + layout_warnings: [] + }; + } + + const elkRuntime = resolveElkRuntime(options.elk_runtime_module); + if (!elkRuntime.ok) { + return { + ...nativeLayout, + layout_engine_requested: "elk", + layout_engine_used: DEFAULT_LAYOUT_ENGINE, + layout_warnings: [ + { + code: "elk_layout_unavailable_fallback", + message: elkRuntime.message + } + ] + }; + } + + return { + ...nativeLayout, + layout_engine_requested: "elk", + layout_engine_used: DEFAULT_LAYOUT_ENGINE, + layout_warnings: [ + { + code: "elk_layout_boundary_fallback", + message: + "ELK runtime resolved, but backend ELK placement is not yet enabled. Using default layout engine." + } + ] + }; +} + export function netAnchorPoint(net, model, placed) { const first = net.nodes[0]; if (!first) { diff --git a/tests/api-contract.test.js b/tests/api-contract.test.js index 5c7df63..de42f14 100644 --- a/tests/api-contract.test.js +++ b/tests/api-contract.test.js @@ -27,6 +27,9 @@ test("REST compile contract shape is stable with version metadata", () => { assert.ok(Array.isArray(body.warnings)); assert.ok(Array.isArray(body.bus_groups)); assert.equal(typeof body.render_mode_used, "string"); + assert.equal(typeof body.layout_engine_requested, "string"); + assert.equal(typeof body.layout_engine_used, "string"); + assert.ok(Array.isArray(body.layout_warnings)); assert.equal(typeof body.svg, "string"); }); diff --git a/tests/compile.test.js b/tests/compile.test.js index e393292..8aa7322 100644 --- a/tests/compile.test.js +++ b/tests/compile.test.js @@ -36,6 +36,47 @@ test("compile accepts render mode options", () => { assert.equal(result.render_mode_used, "explicit"); }); +test("compile default layout engine path remains stable", () => { + const baseline = compile(fixture); + const explicitDefault = compile(fixture, { use_elk_layout: false }); + + assert.equal(baseline.ok, true); + assert.deepEqual(explicitDefault.layout, baseline.layout); + assert.deepEqual(explicitDefault.layout_metrics, baseline.layout_metrics); + assert.equal(baseline.layout_engine_requested, "schemeta-v2"); + assert.equal(baseline.layout_engine_used, "schemeta-v2"); + assert.deepEqual(baseline.layout_warnings, []); +}); + +test("compile accepts ELK layout flag option", () => { + const result = compile(fixture, { + use_elk_layout: true, + elk_runtime_module: "__missing_elk_runtime_for_test__" + }); + + assert.equal(result.ok, true); + assert.equal(result.layout_engine_requested, "elk"); + assert.equal(result.layout_engine_used, "schemeta-v2"); +}); + +test("compile ELK fallback is deterministic when runtime is unavailable", () => { + const options = { + use_elk_layout: true, + elk_runtime_module: "__missing_elk_runtime_for_test__" + }; + const runA = compile(fixture, options); + const runB = compile(fixture, options); + + assert.equal(runA.ok, true); + assert.deepEqual(runB.layout, runA.layout); + assert.deepEqual(runB.layout_metrics, runA.layout_metrics); + assert.equal(runA.layout_engine_requested, "elk"); + assert.equal(runA.layout_engine_used, "schemeta-v2"); + assert.equal(runA.layout_warnings.length, 1); + assert.equal(runA.layout_warnings[0].code, "elk_layout_unavailable_fallback"); + assert.deepEqual(runB.layout_warnings, runA.layout_warnings); +}); + test("compile auto-creates generic symbols for unknown instances", () => { const model = { meta: { title: "Generic Demo" },