Sprint 2: wire React workflows, store lifecycle, and interactive canvas
Some checks are pending
CI / test (push) Waiting to run
Some checks are pending
CI / test (push) Waiting to run
This commit is contained in:
parent
d029e480d0
commit
5a4e116475
@ -1,87 +1,191 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { analyze, autoLayout, compile, tidyLayout } from "./api/client";
|
import { analyze, autoLayout, compile, tidyLayout } from "./api/client";
|
||||||
import { CanvasArea } from "./components/CanvasArea";
|
import { CanvasArea } from "./components/CanvasArea";
|
||||||
import { LeftPanel } from "./components/LeftPanel";
|
import { LeftPanel } from "./components/LeftPanel";
|
||||||
import { RightInspector } from "./components/RightInspector";
|
import { RightInspector } from "./components/RightInspector";
|
||||||
import { TopBar } from "./components/TopBar";
|
import { TopBar } from "./components/TopBar";
|
||||||
|
import type { TopBarStatusTone } from "./components/TopBar";
|
||||||
|
import sampleFixture from "./fixtures/sample.schemeta.json";
|
||||||
|
import { useSchemetaActions, useSchemetaSelector } from "./hooks";
|
||||||
|
import { createSchemetaStore } from "./state";
|
||||||
|
import type { SchemetaModel } from "./state";
|
||||||
|
|
||||||
const SAMPLE_PAYLOAD = {
|
type EndpointActionId = "compile" | "analyze" | "layout-auto" | "layout-tidy";
|
||||||
symbols: {
|
type LocalActionId = "load-sample" | "reset-sample";
|
||||||
r_std: { template_name: "resistor" }
|
type WorkflowActionId = EndpointActionId | LocalActionId;
|
||||||
},
|
|
||||||
instances: [
|
type WorkflowAction = {
|
||||||
{
|
id: WorkflowActionId;
|
||||||
ref: "R1",
|
label: string;
|
||||||
symbol: "r_std",
|
|
||||||
properties: { value: "10k" },
|
|
||||||
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
nets: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function App() {
|
const SAMPLE_PAYLOAD = sampleFixture as SchemetaModel;
|
||||||
const [status, setStatus] = useState("Idle");
|
|
||||||
const [lastApiVersion, setLastApiVersion] = useState("-");
|
|
||||||
|
|
||||||
const actions = useMemo(
|
function getPayloadOrThrow(payload: unknown): SchemetaModel {
|
||||||
() => [
|
if (typeof payload !== "object" || payload === null) {
|
||||||
{
|
throw new Error("No model loaded. Run 'Load Sample JSON' first.");
|
||||||
id: "compile",
|
|
||||||
label: "Compile",
|
|
||||||
run: async () => compile(SAMPLE_PAYLOAD)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analyze",
|
|
||||||
label: "Analyze",
|
|
||||||
run: async () => analyze(SAMPLE_PAYLOAD)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "layout-auto",
|
|
||||||
label: "Auto Layout",
|
|
||||||
run: async () => autoLayout(SAMPLE_PAYLOAD)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "layout-tidy",
|
|
||||||
label: "Auto Tidy",
|
|
||||||
run: async () => tidyLayout(SAMPLE_PAYLOAD)
|
|
||||||
}
|
}
|
||||||
|
return payload as SchemetaModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCompileFromAnalyze(result: Awaited<ReturnType<typeof analyze>>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
api_version: result.api_version,
|
||||||
|
schema_version: result.schema_version,
|
||||||
|
request_id: result.request_id,
|
||||||
|
ok: result.ok ?? true,
|
||||||
|
errors: Array.isArray(result.errors) ? result.errors : [],
|
||||||
|
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
||||||
|
topology: result.topology ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const store = useMemo(() => createSchemetaStore({ model: SAMPLE_PAYLOAD }), []);
|
||||||
|
const actionsApi = useSchemetaActions(store);
|
||||||
|
const model = useSchemetaSelector(store, (state) => state.model);
|
||||||
|
const selection = useSchemetaSelector(store, (state) => state.selection);
|
||||||
|
const compileResult = useSchemetaSelector(store, (state) => state.compileResult);
|
||||||
|
const lifecycle = useSchemetaSelector(store, (state) => state.lifecycle);
|
||||||
|
const jsonError = useSchemetaSelector(store, (state) => state.uiFlags.jsonError);
|
||||||
|
const lastApiVersion = useSchemetaSelector(store, (state) => {
|
||||||
|
const apiVersion = state.compileResult && typeof state.compileResult.api_version === "string" ? state.compileResult.api_version : null;
|
||||||
|
return apiVersion ?? "-";
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = useMemo<WorkflowAction[]>(
|
||||||
|
() => [
|
||||||
|
{ id: "load-sample", label: "Load Sample JSON" },
|
||||||
|
{ id: "compile", label: "Compile" },
|
||||||
|
{ id: "analyze", label: "Analyze" },
|
||||||
|
{ id: "layout-auto", label: "Auto Layout" },
|
||||||
|
{ id: "layout-tidy", label: "Auto Tidy" },
|
||||||
|
{ id: "reset-sample", label: "Reset Sample" }
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function runAction(actionId: string) {
|
const status = jsonError
|
||||||
|
? `JSON parse failed: ${jsonError}`
|
||||||
|
: lifecycle.isCompiling
|
||||||
|
? `${lifecycle.lastAction ?? "Working"}...`
|
||||||
|
: lifecycle.lastError
|
||||||
|
? `${lifecycle.lastAction ?? "Action"} failed: ${lifecycle.lastError}`
|
||||||
|
: lifecycle.lastAction
|
||||||
|
? `${lifecycle.lastAction} complete`
|
||||||
|
: "Idle";
|
||||||
|
const statusTone: TopBarStatusTone = jsonError
|
||||||
|
? "error"
|
||||||
|
: lifecycle.isCompiling
|
||||||
|
? "busy"
|
||||||
|
: lifecycle.lastError
|
||||||
|
? "error"
|
||||||
|
: lifecycle.lastAction
|
||||||
|
? "success"
|
||||||
|
: "idle";
|
||||||
|
|
||||||
|
async function runAction(actionId: WorkflowActionId) {
|
||||||
const action = actions.find((item) => item.id === actionId);
|
const action = actions.find((item) => item.id === actionId);
|
||||||
if (!action) {
|
if (!action) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(`${action.label}...`);
|
actionsApi.beginCompile(action.label);
|
||||||
try {
|
try {
|
||||||
const result = await action.run();
|
if (action.id === "load-sample" || action.id === "reset-sample") {
|
||||||
setLastApiVersion(result.api_version ?? "-");
|
const parsed = actionsApi.applyJsonText(JSON.stringify(SAMPLE_PAYLOAD));
|
||||||
setStatus(`${action.label} complete`);
|
if (!parsed.ok) {
|
||||||
|
throw new Error(parsed.error);
|
||||||
|
}
|
||||||
|
actionsApi.setCompileResult(null);
|
||||||
|
actionsApi.setSelection({ selectedRefs: [], selectedNet: null, selectedPin: null });
|
||||||
|
actionsApi.setViewport({ scale: 1, panX: 40, panY: 40 });
|
||||||
|
actionsApi.completeCompile(action.label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = getPayloadOrThrow(store.getState().model);
|
||||||
|
|
||||||
|
switch (action.id) {
|
||||||
|
case "compile":
|
||||||
|
{
|
||||||
|
const result = await compile(payload);
|
||||||
|
actionsApi.setCompileResult(result);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "analyze":
|
||||||
|
{
|
||||||
|
const result = await analyze(payload);
|
||||||
|
actionsApi.setCompileResult(toCompileFromAnalyze(result));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "layout-auto":
|
||||||
|
{
|
||||||
|
const result = await autoLayout(payload);
|
||||||
|
if (result.model) {
|
||||||
|
actionsApi.setModel(result.model);
|
||||||
|
}
|
||||||
|
if (result.compile) {
|
||||||
|
actionsApi.setCompileResult(result.compile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "layout-tidy":
|
||||||
|
{
|
||||||
|
const result = await tidyLayout(payload);
|
||||||
|
if (result.model) {
|
||||||
|
actionsApi.setModel(result.model);
|
||||||
|
}
|
||||||
|
if (result.compile) {
|
||||||
|
actionsApi.setCompileResult(result.compile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsApi.completeCompile(action.label);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
actionsApi.failCompile(action.label, err);
|
||||||
setStatus(`${action.label} failed: ${message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<TopBar title="React Frontend Skeleton" />
|
<TopBar
|
||||||
<div className="toolbar" role="toolbar" aria-label="API actions">
|
title="React Frontend Skeleton"
|
||||||
{actions.map((action) => (
|
actions={actions.map((action) => ({
|
||||||
<button key={action.id} type="button" onClick={() => runAction(action.id)}>
|
id: action.id,
|
||||||
{action.label}
|
label: action.label,
|
||||||
</button>
|
onClick: () => {
|
||||||
))}
|
void runAction(action.id);
|
||||||
<div className="toolbar__meta">Status: {status} | API: {lastApiVersion}</div>
|
},
|
||||||
</div>
|
disabled: lifecycle.isCompiling
|
||||||
|
}))}
|
||||||
|
statusMessage={status}
|
||||||
|
statusTone={statusTone}
|
||||||
|
apiVersion={lastApiVersion}
|
||||||
|
/>
|
||||||
<div className="workspace-grid">
|
<div className="workspace-grid">
|
||||||
<LeftPanel />
|
<LeftPanel
|
||||||
|
model={model}
|
||||||
|
selection={selection}
|
||||||
|
onSelectInstance={(ref) =>
|
||||||
|
actionsApi.setSelection({
|
||||||
|
selectedRefs: selection.selectedRefs.includes(ref) ? [] : [ref],
|
||||||
|
selectedNet: null,
|
||||||
|
selectedPin: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSelectNet={(name) =>
|
||||||
|
actionsApi.setSelection({
|
||||||
|
selectedRefs: [],
|
||||||
|
selectedNet: selection.selectedNet === name ? null : name,
|
||||||
|
selectedPin: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<CanvasArea />
|
<CanvasArea />
|
||||||
<RightInspector />
|
<RightInspector model={model} selection={selection} compileResult={compileResult} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,304 @@
|
|||||||
export function CanvasArea() {
|
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { createSchemetaStore } from "../state/store.js";
|
||||||
|
import type { SchemetaInstance, SchemetaModel, SchemetaStore } from "../state/store.js";
|
||||||
|
|
||||||
|
const MIN_SCALE = 0.4;
|
||||||
|
const MAX_SCALE = 2.5;
|
||||||
|
const SCALE_STEP = 0.1;
|
||||||
|
const AUTO_COLUMNS = 4;
|
||||||
|
const AUTO_X_START = 60;
|
||||||
|
const AUTO_Y_START = 60;
|
||||||
|
const AUTO_X_GAP = 210;
|
||||||
|
const AUTO_Y_GAP = 150;
|
||||||
|
|
||||||
|
const BOOTSTRAP_MODEL: SchemetaModel = {
|
||||||
|
symbols: {
|
||||||
|
mcu: {
|
||||||
|
body: { width: 180, height: 140 },
|
||||||
|
pins: [
|
||||||
|
{ name: "3V3", number: "1", side: "left", offset: 28 },
|
||||||
|
{ name: "GND", number: "2", side: "left", offset: 56 },
|
||||||
|
{ name: "GPIO1", number: "6", side: "right", offset: 40 },
|
||||||
|
{ name: "GPIO2", number: "7", side: "right", offset: 72 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sensor: {
|
||||||
|
body: { width: 150, height: 110 },
|
||||||
|
pins: [
|
||||||
|
{ name: "3V3", number: "1", side: "left", offset: 30 },
|
||||||
|
{ name: "GND", number: "2", side: "left", offset: 58 },
|
||||||
|
{ name: "SCL", number: "3", side: "right", offset: 40 },
|
||||||
|
{ name: "SDA", number: "4", side: "right", offset: 68 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
conn: {
|
||||||
|
body: { width: 130, height: 90 },
|
||||||
|
pins: [
|
||||||
|
{ name: "TX", number: "1", side: "right", offset: 30 },
|
||||||
|
{ name: "RX", number: "2", side: "right", offset: 56 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
instances: [
|
||||||
|
{ ref: "U1", symbol: "mcu", properties: { value: "Control MCU" }, placement: { x: 120, y: 120, rotation: 0 } },
|
||||||
|
{ ref: "U2", symbol: "sensor", properties: { value: "IMU" }, placement: { x: 420, y: 130, rotation: 0 } },
|
||||||
|
{ ref: "U3", symbol: "sensor", properties: { value: "Temp Sensor" }, placement: { x: null, y: null, rotation: 0 } },
|
||||||
|
{ ref: "J1", symbol: "conn", properties: { value: "Debug Header" }, placement: { x: 180, y: 320, rotation: 0 } }
|
||||||
|
],
|
||||||
|
nets: []
|
||||||
|
};
|
||||||
|
|
||||||
|
type SymbolPin = {
|
||||||
|
name?: string;
|
||||||
|
number?: string;
|
||||||
|
side?: string;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodePlacement = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
locked: boolean;
|
||||||
|
leftPins: SymbolPin[];
|
||||||
|
rightPins: SymbolPin[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
ref: string;
|
||||||
|
pointerId: number;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackStore = createSchemetaStore({
|
||||||
|
model: BOOTSTRAP_MODEL,
|
||||||
|
viewport: { scale: 1, panX: 30, panY: 30 },
|
||||||
|
selection: { selectedRefs: [], selectedNet: null, selectedPin: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
function useStoreState(activeStore: SchemetaStore): ReturnType<SchemetaStore["getState"]> {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener: () => void) => activeStore.subscribe(() => listener()),
|
||||||
|
() => activeStore.getState(),
|
||||||
|
() => activeStore.getState()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFinite(value: unknown, fallback: number) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDimension(value: unknown, fallback: number, min = 80, max = 320) {
|
||||||
|
const parsed = toFinite(value, fallback);
|
||||||
|
return Math.max(min, Math.min(max, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) {
|
||||||
|
if (!instance.symbol || !symbols || typeof symbols !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = symbols[instance.symbol];
|
||||||
|
return candidate && typeof candidate === "object" ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodePlacement(instance: SchemetaInstance, index: number, symbols: SchemetaModel["symbols"]): NodePlacement {
|
||||||
|
const col = index % AUTO_COLUMNS;
|
||||||
|
const row = Math.floor(index / AUTO_COLUMNS);
|
||||||
|
const symbol = getSymbolNode(symbols, instance) as
|
||||||
|
| { body?: { width?: number; height?: number }; pins?: SymbolPin[] }
|
||||||
|
| null;
|
||||||
|
const width = toDimension(symbol?.body?.width, 150);
|
||||||
|
const height = toDimension(symbol?.body?.height, 100);
|
||||||
|
const x = toFinite(instance.placement?.x, AUTO_X_START + col * AUTO_X_GAP);
|
||||||
|
const y = toFinite(instance.placement?.y, AUTO_Y_START + row * AUTO_Y_GAP);
|
||||||
|
const rotation = toFinite(instance.placement?.rotation, 0);
|
||||||
|
const locked = Boolean(instance.placement?.locked);
|
||||||
|
const pins = Array.isArray(symbol?.pins) ? symbol.pins : [];
|
||||||
|
const leftPins = pins.filter((pin) => pin?.side === "left").slice(0, 6);
|
||||||
|
const rightPins = pins.filter((pin) => pin?.side === "right").slice(0, 6);
|
||||||
|
|
||||||
|
return { x, y, width, height, rotation, locked, leftPins, rightPins };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZoom(scale: number) {
|
||||||
|
return `${Math.round(scale * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanvasAreaProps = {
|
||||||
|
store?: SchemetaStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
||||||
|
const store = providedStore ?? fallbackStore;
|
||||||
|
const state = useStoreState(store);
|
||||||
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
|
const instances = state.model?.instances ?? [];
|
||||||
|
const symbols = state.model?.symbols ?? {};
|
||||||
|
const selectedRef = state.selection.selectedRefs[0] ?? null;
|
||||||
|
|
||||||
|
const placements = useMemo(() => {
|
||||||
|
const byRef = new Map<string, NodePlacement>();
|
||||||
|
instances.forEach((instance, index) => {
|
||||||
|
byRef.set(instance.ref, getNodePlacement(instance, index, symbols));
|
||||||
|
});
|
||||||
|
return byRef;
|
||||||
|
}, [instances, symbols]);
|
||||||
|
|
||||||
|
function setSelection(ref: string | null) {
|
||||||
|
store.actions.setSelection({
|
||||||
|
selectedRefs: ref ? [ref] : [],
|
||||||
|
selectedNet: null,
|
||||||
|
selectedPin: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale(nextScale: number) {
|
||||||
|
const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
|
||||||
|
store.actions.setViewport({ scale: clamped });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
setSelection(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodePointerDown(event: ReactPointerEvent<HTMLDivElement>, ref: string, placement: NodePlacement) {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(ref);
|
||||||
|
if (placement.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setDrag({
|
||||||
|
ref,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
startX: placement.x,
|
||||||
|
startY: placement.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodePointerMove(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
|
||||||
|
if (!drag || drag.ref !== ref || 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;
|
||||||
|
store.actions.moveComponent(ref, { x: drag.startX + dx, y: drag.startY + dy });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodePointerUpOrCancel(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
|
||||||
|
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
setDrag(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="canvas" aria-label="Canvas area">
|
<main className="canvas" aria-label="Canvas area">
|
||||||
<div className="canvas__placeholder">Canvas Area Placeholder</div>
|
<div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateScale(state.viewport.scale - SCALE_STEP)}
|
||||||
|
disabled={state.viewport.scale <= MIN_SCALE}
|
||||||
|
>
|
||||||
|
Zoom Out
|
||||||
|
</button>
|
||||||
|
<div className="canvas__zoom-readout">Zoom {formatZoom(state.viewport.scale)}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateScale(state.viewport.scale + SCALE_STEP)}
|
||||||
|
disabled={state.viewport.scale >= MAX_SCALE}
|
||||||
|
>
|
||||||
|
Zoom In
|
||||||
|
</button>
|
||||||
|
<button type="button" className="canvas__placeholder-control" disabled title="Fit viewport is not implemented yet">
|
||||||
|
Fit (Soon)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="canvas__placeholder-control"
|
||||||
|
disabled
|
||||||
|
title="Focus selection is not implemented yet"
|
||||||
|
>
|
||||||
|
Focus (Soon)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="canvas__surface" onPointerDown={handleCanvasPointerDown}>
|
||||||
|
<div
|
||||||
|
className="canvas__viewport"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{instances.map((instance, index) => {
|
||||||
|
const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols);
|
||||||
|
const isSelected = selectedRef === instance.ref;
|
||||||
|
const valueLabel =
|
||||||
|
typeof instance.properties?.value === "string" && instance.properties.value.length > 0
|
||||||
|
? instance.properties.value
|
||||||
|
: instance.symbol ?? instance.part ?? "Component";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.ref}
|
||||||
|
className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.ref === instance.ref ? "is-dragging" : ""}`}
|
||||||
|
style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }}
|
||||||
|
onPointerDown={(event) => handleNodePointerDown(event, instance.ref, placement)}
|
||||||
|
onPointerMove={(event) => handleNodePointerMove(event, instance.ref)}
|
||||||
|
onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
|
||||||
|
onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
|
||||||
|
>
|
||||||
|
<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__ref">{instance.ref}</div>
|
||||||
|
<div className="canvas-node__value">{valueLabel}</div>
|
||||||
|
<div className="canvas-node__pins">
|
||||||
|
<div className="canvas-node__pin-col">
|
||||||
|
{placement.leftPins.map((pin) => (
|
||||||
|
<div key={`${instance.ref}-left-${pin.number ?? pin.name}`} className="canvas-node__pin">
|
||||||
|
<span className="canvas-node__pin-number">{pin.number ?? "-"}</span>
|
||||||
|
<span className="canvas-node__pin-name">{pin.name ?? "PIN"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="canvas-node__pin-col canvas-node__pin-col--right">
|
||||||
|
{placement.rightPins.map((pin) => (
|
||||||
|
<div key={`${instance.ref}-right-${pin.number ?? pin.name}`} className="canvas-node__pin">
|
||||||
|
<span className="canvas-node__pin-name">{pin.name ?? "PIN"}</span>
|
||||||
|
<span className="canvas-node__pin-number">{pin.number ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{placement.locked ? <div className="canvas-node__lock">Locked</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,98 @@
|
|||||||
export function LeftPanel() {
|
import { useMemo, useState } from "react";
|
||||||
|
import type { SchemetaModel, SelectionSlice } from "../state";
|
||||||
|
|
||||||
|
type LeftPanelProps = {
|
||||||
|
model: SchemetaModel | null;
|
||||||
|
selection: SelectionSlice;
|
||||||
|
onSelectInstance: (ref: string) => void;
|
||||||
|
onSelectNet: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NetRow = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asNetLabel(net: Record<string, unknown>, index: number): NetRow {
|
||||||
|
const name = typeof net.name === "string" && net.name.length > 0 ? net.name : null;
|
||||||
|
const id = name ?? `net-${index + 1}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: name ?? `Net ${index + 1}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftPanel({ model, selection, onSelectInstance, onSelectNet }: LeftPanelProps) {
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
|
||||||
|
const filter = filterText.trim().toLowerCase();
|
||||||
|
const instances = model?.instances ?? [];
|
||||||
|
const netsRaw = (model?.nets ?? []).filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null);
|
||||||
|
const nets = netsRaw.map(asNetLabel);
|
||||||
|
|
||||||
|
const filteredInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
instances.filter((instance) => {
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const symbol = typeof instance.symbol === "string" ? instance.symbol : "";
|
||||||
|
const part = typeof instance.part === "string" ? instance.part : "";
|
||||||
|
return `${instance.ref} ${symbol} ${part}`.toLowerCase().includes(filter);
|
||||||
|
}),
|
||||||
|
[filter, instances]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredNets = useMemo(
|
||||||
|
() =>
|
||||||
|
nets.filter((net) => {
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return net.label.toLowerCase().includes(filter) || net.id.toLowerCase().includes(filter);
|
||||||
|
}),
|
||||||
|
[filter, nets]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="panel panel--left" aria-label="Left panel">
|
<aside className="panel panel--left" aria-label="Left panel">
|
||||||
<h2 className="panel__title">Left Panel</h2>
|
<h2 className="panel__title">Model Browser</h2>
|
||||||
<p className="panel__body">Component tree and graph controls will live here.</p>
|
<input
|
||||||
|
aria-label="Filter instances and nets"
|
||||||
|
type="search"
|
||||||
|
value={filterText}
|
||||||
|
onChange={(event) => setFilterText(event.target.value)}
|
||||||
|
placeholder="Filter refs, parts, nets..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3>Instances ({filteredInstances.length})</h3>
|
||||||
|
<ul>
|
||||||
|
{filteredInstances.map((instance) => {
|
||||||
|
const isSelected = selection.selectedRefs.includes(instance.ref);
|
||||||
|
const descriptor = instance.symbol ?? instance.part ?? "component";
|
||||||
|
return (
|
||||||
|
<li key={instance.ref}>
|
||||||
|
<button type="button" onClick={() => onSelectInstance(instance.ref)}>
|
||||||
|
{isSelected ? "[x]" : "[ ]"} {instance.ref} - {descriptor}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Nets ({filteredNets.length})</h3>
|
||||||
|
<ul>
|
||||||
|
{filteredNets.map((net) => {
|
||||||
|
const isSelected = selection.selectedNet === net.id;
|
||||||
|
return (
|
||||||
|
<li key={net.id}>
|
||||||
|
<button type="button" onClick={() => onSelectNet(net.id)}>
|
||||||
|
{isSelected ? "[x]" : "[ ]"} {net.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,71 @@
|
|||||||
export function RightInspector() {
|
import type { SchemetaModel, SelectionSlice } from "../state";
|
||||||
|
|
||||||
|
type RightInspectorProps = {
|
||||||
|
model: SchemetaModel | null;
|
||||||
|
selection: SelectionSlice;
|
||||||
|
compileResult: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asList(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSnippet(value: unknown, fallback: string): string {
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightInspector({ model, selection, compileResult }: RightInspectorProps) {
|
||||||
|
const selectedRef = selection.selectedRefs[0] ?? null;
|
||||||
|
const selectedInstance = selectedRef ? (model?.instances ?? []).find((instance) => instance.ref === selectedRef) : null;
|
||||||
|
|
||||||
|
const compile = asRecord(compileResult);
|
||||||
|
const errors = asList(compile?.errors);
|
||||||
|
const warnings = asList(compile?.warnings);
|
||||||
|
const topology = compile?.topology;
|
||||||
|
const layoutMetrics = compile?.layout_metrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="panel panel--right" aria-label="Right inspector">
|
<aside className="panel panel--right" aria-label="Right inspector">
|
||||||
<h2 className="panel__title">Right Inspector</h2>
|
<h2 className="panel__title">Selection Summary</h2>
|
||||||
<p className="panel__body">Selection details and properties will live here.</p>
|
<p className="panel__body">Refs: {selection.selectedRefs.length > 0 ? selection.selectedRefs.join(", ") : "None"}</p>
|
||||||
|
<p className="panel__body">Net: {selection.selectedNet ?? "None"}</p>
|
||||||
|
<p className="panel__body">
|
||||||
|
Pin: {selection.selectedPin ? `${selection.selectedPin.ref}.${selection.selectedPin.pin}` : "None"}
|
||||||
|
</p>
|
||||||
|
<p className="panel__body">
|
||||||
|
Focused Component: {selectedInstance ? `${selectedInstance.ref} (${selectedInstance.symbol ?? selectedInstance.part ?? "component"})` : "None"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="panel__title">Diagnostics</h2>
|
||||||
|
<p className="panel__body">Errors: {errors.length}</p>
|
||||||
|
<p className="panel__body">Warnings: {warnings.length}</p>
|
||||||
|
<pre>{toSnippet(errors[0], "No error diagnostics yet.")}</pre>
|
||||||
|
<pre>{toSnippet(warnings[0], "No warning diagnostics yet.")}</pre>
|
||||||
|
|
||||||
|
<h2 className="panel__title">Topology Snippet</h2>
|
||||||
|
<pre>{toSnippet(topology, "No topology available yet.")}</pre>
|
||||||
|
|
||||||
|
<h2 className="panel__title">Layout Metrics</h2>
|
||||||
|
<pre>{toSnippet(layoutMetrics, "No layout metrics available yet.")}</pre>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,37 @@
|
|||||||
type TopBarProps = {
|
export type TopBarAction = {
|
||||||
title: string;
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TopBar({ title }: TopBarProps) {
|
export type TopBarStatusTone = "idle" | "busy" | "success" | "error";
|
||||||
|
|
||||||
|
type TopBarProps = {
|
||||||
|
title: string;
|
||||||
|
actions: TopBarAction[];
|
||||||
|
statusMessage: string;
|
||||||
|
statusTone: TopBarStatusTone;
|
||||||
|
apiVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopBar({ title, actions, statusMessage, statusTone, apiVersion }: TopBarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar" role="banner">
|
<header className="topbar" role="banner">
|
||||||
<div className="topbar__brand">Schemeta</div>
|
<div className="topbar__brand">Schemeta</div>
|
||||||
<div className="topbar__title">{title}</div>
|
<div className="topbar__title">{title}</div>
|
||||||
|
<div className="topbar__actions" role="toolbar" aria-label="Workflow actions">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button key={action.id} type="button" onClick={action.onClick} disabled={action.disabled === true}>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="topbar__meta">
|
||||||
|
<span>Status: {statusMessage}</span>
|
||||||
|
<span>Level: {statusTone}</span>
|
||||||
|
<span>API: {apiVersion}</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
frontend-react/src/fixtures/sample.schemeta.json
Normal file
37
frontend-react/src/fixtures/sample.schemeta.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "Dual-Rail Power Tree",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"symbols": {},
|
||||||
|
"instances": [
|
||||||
|
{ "ref": "J1", "part": "connector", "properties": { "value": "VIN/GND" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U1", "part": "generic", "properties": { "value": "Buck 12V->5V" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U2", "part": "generic", "properties": { "value": "LDO 5V->3V3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U3", "part": "generic", "properties": { "value": "MCU" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U4", "part": "generic", "properties": { "value": "RF Module" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C1", "part": "capacitor", "properties": { "value": "22uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C2", "part": "capacitor", "properties": { "value": "10uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C3", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C4", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
|
||||||
|
],
|
||||||
|
"nets": [
|
||||||
|
{ "name": "VIN", "class": "power", "nodes": [ { "ref": "J1", "pin": "1" }, { "ref": "U1", "pin": "VIN" } ] },
|
||||||
|
{ "name": "5V", "class": "power", "nodes": [ { "ref": "U1", "pin": "VOUT" }, { "ref": "U2", "pin": "VIN" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" } ] },
|
||||||
|
{ "name": "3V3", "class": "power", "nodes": [ { "ref": "U2", "pin": "VOUT" }, { "ref": "U3", "pin": "VCC" }, { "ref": "U4", "pin": "VCC" }, { "ref": "C3", "pin": "1" }, { "ref": "C4", "pin": "1" } ] },
|
||||||
|
{ "name": "GND", "class": "ground", "nodes": [ { "ref": "J1", "pin": "2" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U4", "pin": "GND" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }, { "ref": "C4", "pin": "2" } ] },
|
||||||
|
{ "name": "SPI_CLK", "class": "clock", "nodes": [ { "ref": "U3", "pin": "SCLK" }, { "ref": "U4", "pin": "SCLK" } ] },
|
||||||
|
{ "name": "SPI_MOSI", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MOSI" }, { "ref": "U4", "pin": "MOSI" } ] },
|
||||||
|
{ "name": "SPI_MISO", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MISO" }, { "ref": "U4", "pin": "MISO" } ] }
|
||||||
|
],
|
||||||
|
"constraints": {
|
||||||
|
"groups": [
|
||||||
|
{ "name": "source", "members": ["J1", "U1", "C1"], "layout": "cluster" },
|
||||||
|
{ "name": "regulation", "members": ["U2", "C2"], "layout": "cluster" },
|
||||||
|
{ "name": "load", "members": ["U3", "U4", "C3", "C4"], "layout": "cluster" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"annotations": [
|
||||||
|
{ "text": "Power-chain fixture with SPI branch used for readability QA.", "x": 24, "y": 24 }
|
||||||
|
]
|
||||||
|
}
|
||||||
1
frontend-react/src/hooks/index.ts
Normal file
1
frontend-react/src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useSchemetaActions, useSchemetaSelector, useSchemetaStoreState } from "./useSchemetaStore";
|
||||||
17
frontend-react/src/hooks/useSchemetaStore.ts
Normal file
17
frontend-react/src/hooks/useSchemetaStore.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
|
import type { SchemetaStore, StoreState } from "../state";
|
||||||
|
|
||||||
|
type Selector<TSelected> = (state: StoreState) => TSelected;
|
||||||
|
|
||||||
|
export function useSchemetaStoreState(store: SchemetaStore): StoreState {
|
||||||
|
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchemetaSelector<TSelected>(store: SchemetaStore, selector: Selector<TSelected>): TSelected {
|
||||||
|
const state = useSchemetaStoreState(store);
|
||||||
|
return useMemo(() => selector(state), [selector, state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchemetaActions(store: SchemetaStore): SchemetaStore["actions"] {
|
||||||
|
return store.actions;
|
||||||
|
}
|
||||||
14
frontend-react/src/state/store.d.ts
vendored
14
frontend-react/src/state/store.d.ts
vendored
@ -23,6 +23,9 @@ export type SchemetaModel = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CompileResult = {
|
export type CompileResult = {
|
||||||
|
api_version?: string;
|
||||||
|
schema_version?: string;
|
||||||
|
request_id?: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
errors: Array<Record<string, unknown>>;
|
errors: Array<Record<string, unknown>>;
|
||||||
warnings: Array<Record<string, unknown>>;
|
warnings: Array<Record<string, unknown>>;
|
||||||
@ -52,6 +55,12 @@ export type UiFlagsSlice = {
|
|||||||
jsonError: string | null;
|
jsonError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LifecycleSlice = {
|
||||||
|
isCompiling: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
lastAction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type StateSnapshot = {
|
export type StateSnapshot = {
|
||||||
model: SchemetaModel | null;
|
model: SchemetaModel | null;
|
||||||
compileResult: CompileResult | null;
|
compileResult: CompileResult | null;
|
||||||
@ -66,6 +75,7 @@ export type StoreState = {
|
|||||||
selection: SelectionSlice;
|
selection: SelectionSlice;
|
||||||
viewport: ViewportSlice;
|
viewport: ViewportSlice;
|
||||||
uiFlags: UiFlagsSlice;
|
uiFlags: UiFlagsSlice;
|
||||||
|
lifecycle: LifecycleSlice;
|
||||||
history: {
|
history: {
|
||||||
past: Array<{ label: string; snapshot: StateSnapshot }>;
|
past: Array<{ label: string; snapshot: StateSnapshot }>;
|
||||||
future: Array<{ label: string; snapshot: StateSnapshot }>;
|
future: Array<{ label: string; snapshot: StateSnapshot }>;
|
||||||
@ -89,6 +99,10 @@ export type SchemetaStore = {
|
|||||||
moveComponent(ref: string, placement: Partial<SchemetaPlacement>): void;
|
moveComponent(ref: string, placement: Partial<SchemetaPlacement>): void;
|
||||||
setViewport(viewport: Partial<ViewportSlice>): void;
|
setViewport(viewport: Partial<ViewportSlice>): void;
|
||||||
setUiFlags(uiFlags: Partial<UiFlagsSlice>): void;
|
setUiFlags(uiFlags: Partial<UiFlagsSlice>): void;
|
||||||
|
setLifecycle(lifecycle: Partial<LifecycleSlice>): void;
|
||||||
|
beginCompile(action: string): void;
|
||||||
|
completeCompile(action: string): void;
|
||||||
|
failCompile(action: string, error: unknown): void;
|
||||||
applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string };
|
applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string };
|
||||||
beginTransaction(label?: string): boolean;
|
beginTransaction(label?: string): boolean;
|
||||||
commitTransaction(): boolean;
|
commitTransaction(): boolean;
|
||||||
|
|||||||
@ -29,6 +29,9 @@ const DEFAULT_HISTORY_LIMIT = 80;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} CompileResult
|
* @typedef {Object} CompileResult
|
||||||
|
* @property {string} [api_version]
|
||||||
|
* @property {string} [schema_version]
|
||||||
|
* @property {string} [request_id]
|
||||||
* @property {boolean} ok
|
* @property {boolean} ok
|
||||||
* @property {Array<Record<string, unknown>>} errors
|
* @property {Array<Record<string, unknown>>} errors
|
||||||
* @property {Array<Record<string, unknown>>} warnings
|
* @property {Array<Record<string, unknown>>} warnings
|
||||||
@ -65,6 +68,13 @@ const DEFAULT_HISTORY_LIMIT = 80;
|
|||||||
* @property {string | null} jsonError
|
* @property {string | null} jsonError
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} LifecycleSlice
|
||||||
|
* @property {boolean} isCompiling
|
||||||
|
* @property {string | null} lastError
|
||||||
|
* @property {string | null} lastAction
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} StateSnapshot
|
* @typedef {Object} StateSnapshot
|
||||||
* @property {SchemetaModel | null} model
|
* @property {SchemetaModel | null} model
|
||||||
@ -87,6 +97,7 @@ const DEFAULT_HISTORY_LIMIT = 80;
|
|||||||
* @property {SelectionSlice} selection
|
* @property {SelectionSlice} selection
|
||||||
* @property {ViewportSlice} viewport
|
* @property {ViewportSlice} viewport
|
||||||
* @property {UiFlagsSlice} uiFlags
|
* @property {UiFlagsSlice} uiFlags
|
||||||
|
* @property {LifecycleSlice} lifecycle
|
||||||
* @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history
|
* @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history
|
||||||
* @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction
|
* @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction
|
||||||
*/
|
*/
|
||||||
@ -157,6 +168,25 @@ function normalizeUiFlags(uiFlags, prev) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Partial<LifecycleSlice>} lifecycle
|
||||||
|
* @param {LifecycleSlice} prev
|
||||||
|
* @returns {LifecycleSlice}
|
||||||
|
*/
|
||||||
|
function normalizeLifecycle(lifecycle, prev) {
|
||||||
|
return {
|
||||||
|
isCompiling: typeof lifecycle.isCompiling === "boolean" ? lifecycle.isCompiling : prev.isCompiling,
|
||||||
|
lastError:
|
||||||
|
lifecycle.lastError === null || (typeof lifecycle.lastError === "string" && lifecycle.lastError.length > 0)
|
||||||
|
? lifecycle.lastError
|
||||||
|
: prev.lastError,
|
||||||
|
lastAction:
|
||||||
|
lifecycle.lastAction === null || (typeof lifecycle.lastAction === "string" && lifecycle.lastAction.length > 0)
|
||||||
|
? lifecycle.lastAction
|
||||||
|
: prev.lastAction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {StoreState} state
|
* @param {StoreState} state
|
||||||
* @returns {StateSnapshot}
|
* @returns {StateSnapshot}
|
||||||
@ -413,6 +443,18 @@ function applyJsonTextFailureState(state, text) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StoreState} state
|
||||||
|
* @param {Partial<LifecycleSlice>} lifecycle
|
||||||
|
* @returns {StoreState}
|
||||||
|
*/
|
||||||
|
function setLifecycleState(state, lifecycle) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lifecycle: normalizeLifecycle(lifecycle, state.lifecycle)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Partial<StoreState>} [overrides]
|
* @param {Partial<StoreState>} [overrides]
|
||||||
* @returns {StoreState}
|
* @returns {StoreState}
|
||||||
@ -432,6 +474,11 @@ export function createInitialState(overrides = {}) {
|
|||||||
renderMode: "schematic_stub",
|
renderMode: "schematic_stub",
|
||||||
jsonError: null
|
jsonError: null
|
||||||
}),
|
}),
|
||||||
|
lifecycle: normalizeLifecycle(overrides.lifecycle ?? {}, {
|
||||||
|
isCompiling: false,
|
||||||
|
lastError: null,
|
||||||
|
lastAction: null
|
||||||
|
}),
|
||||||
history: {
|
history: {
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
@ -508,6 +555,48 @@ export function createSchemetaStore(initialState = {}) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Partial<LifecycleSlice>} lifecycle */
|
||||||
|
setLifecycle(lifecycle) {
|
||||||
|
publish(setLifecycleState(state, lifecycle ?? {}), "setLifecycle");
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} action */
|
||||||
|
beginCompile(action) {
|
||||||
|
publish(
|
||||||
|
setLifecycleState(state, {
|
||||||
|
isCompiling: true,
|
||||||
|
lastAction: action || "compile",
|
||||||
|
lastError: null
|
||||||
|
}),
|
||||||
|
"beginCompile"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} action */
|
||||||
|
completeCompile(action) {
|
||||||
|
publish(
|
||||||
|
setLifecycleState(state, {
|
||||||
|
isCompiling: false,
|
||||||
|
lastAction: action || state.lifecycle.lastAction,
|
||||||
|
lastError: null
|
||||||
|
}),
|
||||||
|
"completeCompile"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} action @param {unknown} error */
|
||||||
|
failCompile(action, error) {
|
||||||
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
||||||
|
publish(
|
||||||
|
setLifecycleState(state, {
|
||||||
|
isCompiling: false,
|
||||||
|
lastAction: action || state.lifecycle.lastAction,
|
||||||
|
lastError: message
|
||||||
|
}),
|
||||||
|
"failCompile"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
/** @param {string} jsonText */
|
/** @param {string} jsonText */
|
||||||
applyJsonText(jsonText) {
|
applyJsonText(jsonText) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -43,6 +43,41 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions button {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid rgba(232, 242, 250, 0.65);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(8, 24, 38, 0.24);
|
||||||
|
color: #f2f8ff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions button:hover:not(:disabled) {
|
||||||
|
background: rgba(8, 24, 38, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.1rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: #d7e6f6;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -91,6 +126,7 @@ body {
|
|||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel__title {
|
.panel__title {
|
||||||
@ -103,20 +139,227 @@ body {
|
|||||||
color: #52626e;
|
color: #52626e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas {
|
.panel input[type="search"] {
|
||||||
display: grid;
|
width: 100%;
|
||||||
place-items: center;
|
margin-bottom: 0.7rem;
|
||||||
background: radial-gradient(circle at 20% 20%, #eef5fb 0%, #ffffff 70%);
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid #9bb0c0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas__placeholder {
|
.panel h3 {
|
||||||
border: 1px dashed #91a6b7;
|
margin: 0.7rem 0 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #264258;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel li button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #d2dee8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafcff;
|
||||||
|
padding: 0.32rem 0.45rem;
|
||||||
|
color: #173247;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel li button:hover {
|
||||||
|
background: #eef6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel pre {
|
||||||
|
margin: 0.35rem 0 0.55rem;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.2rem 1.4rem;
|
background: #f7fbff;
|
||||||
color: #355066;
|
color: #274054;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
max-height: 8rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f7fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #dbe5ec;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f3f8fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__toolbar button {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid #8ca1b3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #173247;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__toolbar button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__zoom-readout {
|
||||||
|
min-width: 6rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #21435a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__placeholder-control {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__surface {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: #f7fbff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, #e6edf3 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, #e6edf3 1px, transparent 1px);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas__viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 2200px;
|
||||||
|
height: 1500px;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node {
|
||||||
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__chrome {
|
||||||
|
width: 100%;
|
||||||
|
min-height: inherit;
|
||||||
|
border: 1px solid #406078;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f6fbff 100%);
|
||||||
|
box-shadow: 0 3px 10px rgba(38, 64, 89, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.45rem 0.55rem 0.5rem;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__ref {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #113148;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__value {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #405260;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pins {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.14rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin-col--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin-col--right .canvas-node__pin {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin-number {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: #6c8090;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__pin-name {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #2d475a;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node__lock {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: #925d00;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node.is-selected .canvas-node__chrome {
|
||||||
|
border-color: #0a79c2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(10, 121, 194, 0.25), 0 4px 14px rgba(18, 53, 78, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-node.is-dragging .canvas-node__chrome {
|
||||||
|
cursor: grabbing;
|
||||||
|
box-shadow: 0 0 0 2px rgba(10, 121, 194, 0.3), 0 8px 18px rgba(12, 42, 66, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-grid {
|
.workspace-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto minmax(260px, 1fr) auto;
|
grid-template-rows: auto minmax(260px, 1fr) auto;
|
||||||
|
|||||||
@ -120,3 +120,46 @@ test("transaction scaffold groups multiple actions into one undo step", () => {
|
|||||||
assert.equal(u1.placement.x, null);
|
assert.equal(u1.placement.x, null);
|
||||||
assert.equal(u1.placement.y, null);
|
assert.equal(u1.placement.y, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compile lifecycle state transitions are deterministic", () => {
|
||||||
|
const storeA = createSchemetaStore();
|
||||||
|
const storeB = createSchemetaStore();
|
||||||
|
|
||||||
|
storeA.actions.beginCompile("Compile");
|
||||||
|
storeA.actions.failCompile("Compile", "Bad payload");
|
||||||
|
storeA.actions.beginCompile("Analyze");
|
||||||
|
storeA.actions.completeCompile("Analyze");
|
||||||
|
|
||||||
|
storeB.actions.beginCompile("Compile");
|
||||||
|
storeB.actions.failCompile("Compile", "Bad payload");
|
||||||
|
storeB.actions.beginCompile("Analyze");
|
||||||
|
storeB.actions.completeCompile("Analyze");
|
||||||
|
|
||||||
|
assert.deepEqual(storeA.getState().lifecycle, storeB.getState().lifecycle);
|
||||||
|
assert.equal(storeA.getState().lifecycle.isCompiling, false);
|
||||||
|
assert.equal(storeA.getState().lifecycle.lastError, null);
|
||||||
|
assert.equal(storeA.getState().lifecycle.lastAction, "Analyze");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compile lifecycle updates do not alter undo/redo history", () => {
|
||||||
|
const store = createSchemetaStore();
|
||||||
|
store.actions.setModel(baseModel());
|
||||||
|
store.actions.moveComponent("U1", { x: 90, y: 70 });
|
||||||
|
|
||||||
|
const beforeUndo = store.getState();
|
||||||
|
const pastBeforeLifecycle = beforeUndo.history.past.length;
|
||||||
|
|
||||||
|
store.actions.beginCompile("Compile");
|
||||||
|
store.actions.failCompile("Compile", "Synthetic error");
|
||||||
|
store.actions.beginCompile("Compile");
|
||||||
|
store.actions.completeCompile("Compile");
|
||||||
|
|
||||||
|
assert.equal(store.getState().history.past.length, pastBeforeLifecycle);
|
||||||
|
assert.equal(store.getState().lifecycle.lastAction, "Compile");
|
||||||
|
assert.equal(store.getState().lifecycle.lastError, null);
|
||||||
|
|
||||||
|
store.actions.undo();
|
||||||
|
const u1 = store.getState().model.instances.find((x) => x.ref === "U1");
|
||||||
|
assert.equal(u1.placement.x, null);
|
||||||
|
assert.equal(u1.placement.y, null);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user