Sprint 3: unify React canvas interactions and ELK boundary metadata

This commit is contained in:
Rbanh 2026-02-19 22:56:11 -05:00
parent 5a4e116475
commit dc9c2773de
8 changed files with 1050 additions and 39 deletions

View File

@ -184,7 +184,7 @@ export function App() {
}) })
} }
/> />
<CanvasArea /> <CanvasArea store={store} />
<RightInspector model={model} selection={selection} compileResult={compileResult} /> <RightInspector model={model} selection={selection} compileResult={compileResult} />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useMemo, useState, useSyncExternalStore } from "react"; 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 { createSchemetaStore } from "../state/store.js";
import type { SchemetaInstance, SchemetaModel, SchemetaStore } 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_Y_START = 60;
const AUTO_X_GAP = 210; const AUTO_X_GAP = 210;
const AUTO_Y_GAP = 150; const AUTO_Y_GAP = 150;
const NUDGE_STEP = 10;
const NUDGE_STEP_FAST = 20;
const BOOTSTRAP_MODEL: SchemetaModel = { const BOOTSTRAP_MODEL: SchemetaModel = {
symbols: { symbols: {
@ -67,13 +69,35 @@ type NodePlacement = {
rightPins: SymbolPin[]; 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 = { type DragState = {
ref: string; refs: string[];
pointerId: number; pointerId: number;
startClientX: number; startClientX: number;
startClientY: number; startClientY: number;
startX: number; starts: Map<string, { x: number; y: number }>;
startY: number; };
type BoxSelectState = {
pointerId: number;
start: CanvasPoint;
current: CanvasPoint;
}; };
const fallbackStore = createSchemetaStore({ 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))); return Math.max(min, Math.min(max, Math.round(parsed)));
} }
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null) {
return null;
}
return value as Record<string, unknown>;
}
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) { function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) {
if (!instance.symbol || !symbols || typeof symbols !== "object") { if (!instance.symbol || !symbols || typeof symbols !== "object") {
return null; return null;
@ -126,6 +301,177 @@ function getNodePlacement(instance: SchemetaInstance, index: number, symbols: Sc
return { x, y, width, height, rotation, locked, leftPins, rightPins }; return { x, y, width, height, rotation, locked, leftPins, rightPins };
} }
function toNetIdentity(netRecord: Record<string, unknown>, 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<string, unknown>[],
placements: Map<string, NodePlacement>,
instancesByRef: Map<string, SchemetaInstance>,
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) { function formatZoom(scale: number) {
return `${Math.round(scale * 100)}%`; return `${Math.round(scale * 100)}%`;
} }
@ -134,13 +480,77 @@ type CanvasAreaProps = {
store?: SchemetaStore; 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) { export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
const store = providedStore ?? fallbackStore; const store = providedStore ?? fallbackStore;
const state = useStoreState(store); const state = useStoreState(store);
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
const [boxSelect, setBoxSelect] = useState<BoxSelectState | null>(null);
const instances = state.model?.instances ?? []; const instances = state.model?.instances ?? [];
const symbols = state.model?.symbols ?? {}; 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 placements = useMemo(() => {
const byRef = new Map<string, NodePlacement>(); const byRef = new Map<string, NodePlacement>();
@ -150,12 +560,34 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
return byRef; return byRef;
}, [instances, symbols]); }, [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<string, unknown> => 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({ store.actions.setSelection({
selectedRefs: ref ? [ref] : [], selectedRefs: [],
selectedNet: null, selectedNet: null,
selectedPin: null selectedPin: null
}); });
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
} }
function updateScale(nextScale: number) { function updateScale(nextScale: number) {
@ -163,56 +595,339 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
store.actions.setViewport({ scale: clamped }); store.actions.setViewport({ scale: clamped });
} }
function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) { function eventToCanvasPoint(event: ReactPointerEvent<HTMLElement>): CanvasPoint {
if (event.target === event.currentTarget) { const surface = event.currentTarget;
setSelection(null); 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<HTMLDivElement>, ref: string, placement: NodePlacement) { function beginDrag(refs: string[], event: ReactPointerEvent<HTMLDivElement>) {
if (event.button !== 0) { const starts = new Map<string, { x: number; y: number }>();
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; return;
} }
setSelection(ref); store.actions.beginTransaction(starts.size > 1 ? "drag components" : "drag component");
if (placement.locked) {
return;
}
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
setDrag({ setDrag({
ref, refs: Array.from(starts.keys()),
pointerId: event.pointerId, pointerId: event.pointerId,
startClientX: event.clientX, startClientX: event.clientX,
startClientY: event.clientY, startClientY: event.clientY,
startX: placement.x, starts
startY: placement.y
}); });
} }
function handleNodePointerMove(event: ReactPointerEvent<HTMLDivElement>, ref: string) { function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) {
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) { 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<HTMLElement>) {
if (!boxSelect || boxSelect.pointerId !== event.pointerId) {
return; return;
} }
event.preventDefault(); event.preventDefault();
const dx = (event.clientX - drag.startClientX) / state.viewport.scale; const point = eventToCanvasPoint(event);
const dy = (event.clientY - drag.startClientY) / state.viewport.scale; setBoxSelect((prev) => {
store.actions.moveComponent(ref, { x: drag.startX + dx, y: drag.startY + dy }); if (!prev) {
return null;
}
return {
...prev,
current: point
};
});
} }
function handleNodePointerUpOrCancel(event: ReactPointerEvent<HTMLDivElement>, ref: string) { function handleCanvasPointerUpOrCancel(event: ReactPointerEvent<HTMLElement>) {
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) { if (!boxSelect || boxSelect.pointerId !== event.pointerId) {
return; return;
} }
if (event.currentTarget.hasPointerCapture(event.pointerId)) { if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(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<HTMLDivElement>, 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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLElement>) {
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 ( return (
<main className="canvas" aria-label="Canvas area"> <main className="canvas" aria-label="Canvas area">
<div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls"> <div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls">
@ -244,16 +959,75 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
</button> </button>
</div> </div>
<section className="canvas__surface" onPointerDown={handleCanvasPointerDown}> <section
className="canvas__surface"
onPointerDown={handleCanvasPointerDown}
onPointerMove={handleCanvasPointerMove}
onPointerUp={handleCanvasPointerUpOrCancel}
onPointerCancel={handleCanvasPointerUpOrCancel}
onKeyDown={handleCanvasKeyDown}
tabIndex={0}
>
<div <div
className="canvas__viewport" className="canvas__viewport"
style={{ style={{
transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})` transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})`
}} }}
> >
<svg className="canvas-wires" aria-hidden="true" viewBox="0 0 2200 1500" preserveAspectRatio="none">
{visibleWires.map((wire) =>
wire.segments.map((segment, segmentIndex) => {
const isDimmed = hasSelectedNet && !wire.highlight;
return (
<line
key={`${wire.id}-${segmentIndex}-${segment.a.x}-${segment.a.y}-${segment.b.x}-${segment.b.y}`}
x1={segment.a.x}
y1={segment.a.y}
x2={segment.b.x}
y2={segment.b.y}
className={`canvas-wire-segment ${wire.highlight ? "is-highlight" : ""} ${isDimmed ? "is-dimmed" : ""}`}
data-net-id={wire.id}
data-wire-source={wire.source}
data-net-class={wire.netClass ?? undefined}
onPointerDown={(event) => {
event.stopPropagation();
selectNet(wire.id);
}}
/>
);
})
)}
</svg>
{state.uiFlags.showLabels
? visibleWires.map((wire) => {
if (!wire.labelPoint) {
return null;
}
const isDimmed = hasSelectedNet && !wire.highlight;
return (
<button
type="button"
key={`label-${wire.id}`}
className={`canvas-wire-label ${wire.highlight ? "is-highlight" : ""} ${isDimmed ? "is-dimmed" : ""}`}
style={{ left: `${wire.labelPoint.x}px`, top: `${wire.labelPoint.y}px` }}
data-net-id={wire.id}
onPointerDown={(event) => {
event.stopPropagation();
selectNet(wire.id);
}}
>
{wire.label}
</button>
);
})
: null}
{selectionBoxStyle ? <div className="canvas__selection-box" style={selectionBoxStyle} /> : null}
{instances.map((instance, index) => { {instances.map((instance, index) => {
const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols); const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols);
const isSelected = selectedRef === instance.ref; const isSelected = selectedSet.has(instance.ref);
const valueLabel = const valueLabel =
typeof instance.properties?.value === "string" && instance.properties.value.length > 0 typeof instance.properties?.value === "string" && instance.properties.value.length > 0
? instance.properties.value ? instance.properties.value
@ -262,12 +1036,13 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
return ( return (
<div <div
key={instance.ref} key={instance.ref}
className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.ref === instance.ref ? "is-dragging" : ""}`} className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.refs.includes(instance.ref) ? "is-dragging" : ""}`}
style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }} style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }}
onPointerDown={(event) => handleNodePointerDown(event, instance.ref, placement)} onPointerDown={(event) => handleNodePointerDown(event, instance.ref)}
onPointerMove={(event) => handleNodePointerMove(event, instance.ref)} onPointerMove={handleNodePointerMove}
onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)} onPointerUp={handleNodePointerUpOrCancel}
onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)} onPointerCancel={handleNodePointerUpOrCancel}
tabIndex={-1}
> >
<div className="canvas-node__chrome" style={{ transform: `rotate(${placement.rotation}deg)` }}> <div className="canvas-node__chrome" style={{ transform: `rotate(${placement.rotation}deg)` }}>
<div className="canvas-node__content" style={{ transform: `rotate(${-placement.rotation}deg)` }}> <div className="canvas-node__content" style={{ transform: `rotate(${-placement.rotation}deg)` }}>

View File

@ -242,6 +242,19 @@ body {
background-size: 28px 28px; 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 { .canvas__viewport {
position: relative; position: relative;
width: 2200px; width: 2200px;
@ -249,6 +262,63 @@ body {
transform-origin: 0 0; 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 { .canvas-node {
position: absolute; position: absolute;
user-select: none; user-select: none;

View File

@ -1,5 +1,5 @@
import { analyzeModel } from "./analyze.js"; 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 { renderSvgFromLayout } from "./render.js";
import { validateModel } from "./validate.js"; import { validateModel } from "./validate.js";
@ -239,6 +239,7 @@ function annotateIssues(issues, prefix) {
export function compile(payload, options = {}) { export function compile(payload, options = {}) {
const validated = validateModel(payload, options); const validated = validateModel(payload, options);
const layoutEngineRequested = requestedLayoutEngine(options);
if (!validated.model) { if (!validated.model) {
const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E"); const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
@ -266,6 +267,9 @@ export function compile(payload, options = {}) {
bus_groups: [], bus_groups: [],
focus_map: {}, focus_map: {},
render_mode_used: options.render_mode ?? "schematic_stub", render_mode_used: options.render_mode ?? "schematic_stub",
layout_engine_requested: layoutEngineRequested,
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
layout_warnings: [],
svg: "" svg: ""
}; };
} }
@ -300,6 +304,9 @@ export function compile(payload, options = {}) {
layout_metrics: layout.metrics, layout_metrics: layout.metrics,
bus_groups: layout.bus_groups, bus_groups: layout.bus_groups,
render_mode_used: layout.render_mode_used, 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 svg
}; };
} }

62
src/layout-elk.js Normal file
View File

@ -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;
}

View File

@ -1,3 +1,5 @@
import { resolveElkRuntime } from "./layout-elk.js";
const GRID = 20; const GRID = 20;
const MARGIN_X = 140; const MARGIN_X = 140;
const MARGIN_Y = 140; const MARGIN_Y = 140;
@ -19,6 +21,7 @@ const NET_CLASS_PRIORITY = {
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]); const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
const DEFAULT_RENDER_MODE = "schematic_stub"; const DEFAULT_RENDER_MODE = "schematic_stub";
export const DEFAULT_LAYOUT_ENGINE = "schemeta-v2";
const ROTATION_STEPS = [0, 90, 180, 270]; const ROTATION_STEPS = [0, 90, 180, 270];
const MIN_CHANNEL_SPACING_STEPS = 3; const MIN_CHANNEL_SPACING_STEPS = 3;
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"]; const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
@ -2389,7 +2392,15 @@ export function applyLayoutToModel(model, options = {}) {
return working; 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 renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
const respectLocks = options.respect_locks ?? true; const respectLocks = options.respect_locks ?? true;
const autoRotate = options.auto_rotate ?? 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) { export function netAnchorPoint(net, model, placed) {
const first = net.nodes[0]; const first = net.nodes[0];
if (!first) { if (!first) {

View File

@ -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.warnings));
assert.ok(Array.isArray(body.bus_groups)); assert.ok(Array.isArray(body.bus_groups));
assert.equal(typeof body.render_mode_used, "string"); 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"); assert.equal(typeof body.svg, "string");
}); });

View File

@ -36,6 +36,47 @@ test("compile accepts render mode options", () => {
assert.equal(result.render_mode_used, "explicit"); 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", () => { test("compile auto-creates generic symbols for unknown instances", () => {
const model = { const model = {
meta: { title: "Generic Demo" }, meta: { title: "Generic Demo" },