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" },