diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx index 52e608a..2713708 100644 --- a/frontend-react/src/App.tsx +++ b/frontend-react/src/App.tsx @@ -1,87 +1,191 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { analyze, autoLayout, compile, tidyLayout } from "./api/client"; import { CanvasArea } from "./components/CanvasArea"; import { LeftPanel } from "./components/LeftPanel"; import { RightInspector } from "./components/RightInspector"; 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 = { - symbols: { - r_std: { template_name: "resistor" } - }, - instances: [ - { - ref: "R1", - symbol: "r_std", - properties: { value: "10k" }, - placement: { x: null, y: null, rotation: 0, locked: false } - } - ], - nets: [] +type EndpointActionId = "compile" | "analyze" | "layout-auto" | "layout-tidy"; +type LocalActionId = "load-sample" | "reset-sample"; +type WorkflowActionId = EndpointActionId | LocalActionId; + +type WorkflowAction = { + id: WorkflowActionId; + label: string; }; -export function App() { - const [status, setStatus] = useState("Idle"); - const [lastApiVersion, setLastApiVersion] = useState("-"); +const SAMPLE_PAYLOAD = sampleFixture as SchemetaModel; - 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."); + } + return payload as SchemetaModel; +} + +function toCompileFromAnalyze(result: Awaited>): Record { + 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( () => [ - { - 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) - } + { 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); if (!action) { return; } - setStatus(`${action.label}...`); + actionsApi.beginCompile(action.label); try { - const result = await action.run(); - setLastApiVersion(result.api_version ?? "-"); - setStatus(`${action.label} complete`); + if (action.id === "load-sample" || action.id === "reset-sample") { + const parsed = actionsApi.applyJsonText(JSON.stringify(SAMPLE_PAYLOAD)); + 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) { - const message = err instanceof Error ? err.message : "Unknown error"; - setStatus(`${action.label} failed: ${message}`); + actionsApi.failCompile(action.label, err); } } return (
- -
- {actions.map((action) => ( - - ))} -
Status: {status} | API: {lastApiVersion}
-
+ ({ + id: action.id, + label: action.label, + onClick: () => { + void runAction(action.id); + }, + disabled: lifecycle.isCompiling + }))} + statusMessage={status} + statusTone={statusTone} + apiVersion={lastApiVersion} + />
- + + 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 + }) + } + /> - +
); diff --git a/frontend-react/src/components/CanvasArea.tsx b/frontend-react/src/components/CanvasArea.tsx index e0130b0..7c9ab67 100644 --- a/frontend-react/src/components/CanvasArea.tsx +++ b/frontend-react/src/components/CanvasArea.tsx @@ -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 { + 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(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(); + 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) { + if (event.target === event.currentTarget) { + setSelection(null); + } + } + + function handleNodePointerDown(event: ReactPointerEvent, 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, 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, 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 (
-
Canvas Area Placeholder
+
+ +
Zoom {formatZoom(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 ( +
handleNodePointerDown(event, instance.ref, placement)} + onPointerMove={(event) => handleNodePointerMove(event, instance.ref)} + onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)} + onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)} + > +
+
+
{instance.ref}
+
{valueLabel}
+
+
+ {placement.leftPins.map((pin) => ( +
+ {pin.number ?? "-"} + {pin.name ?? "PIN"} +
+ ))} +
+
+ {placement.rightPins.map((pin) => ( +
+ {pin.name ?? "PIN"} + {pin.number ?? "-"} +
+ ))} +
+
+ {placement.locked ?
Locked
: null} +
+
+
+ ); + })} +
+
); } diff --git a/frontend-react/src/components/LeftPanel.tsx b/frontend-react/src/components/LeftPanel.tsx index 60ef611..6759bfb 100644 --- a/frontend-react/src/components/LeftPanel.tsx +++ b/frontend-react/src/components/LeftPanel.tsx @@ -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, 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 => 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 ( ); } diff --git a/frontend-react/src/components/RightInspector.tsx b/frontend-react/src/components/RightInspector.tsx index 9b2a0ac..878c3fc 100644 --- a/frontend-react/src/components/RightInspector.tsx +++ b/frontend-react/src/components/RightInspector.tsx @@ -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 | null { + if (typeof value !== "object" || value === null) { + return null; + } + return value as Record; +} + +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 ( ); } diff --git a/frontend-react/src/components/TopBar.tsx b/frontend-react/src/components/TopBar.tsx index 3639f14..61ca59a 100644 --- a/frontend-react/src/components/TopBar.tsx +++ b/frontend-react/src/components/TopBar.tsx @@ -1,12 +1,37 @@ -type TopBarProps = { - title: string; +export type TopBarAction = { + 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 (
Schemeta
{title}
+
+ {actions.map((action) => ( + + ))} +
+
+ Status: {statusMessage} + Level: {statusTone} + API: {apiVersion} +
); } diff --git a/frontend-react/src/fixtures/sample.schemeta.json b/frontend-react/src/fixtures/sample.schemeta.json new file mode 100644 index 0000000..735d7c6 --- /dev/null +++ b/frontend-react/src/fixtures/sample.schemeta.json @@ -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 } + ] +} diff --git a/frontend-react/src/hooks/index.ts b/frontend-react/src/hooks/index.ts new file mode 100644 index 0000000..2a1be13 --- /dev/null +++ b/frontend-react/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSchemetaActions, useSchemetaSelector, useSchemetaStoreState } from "./useSchemetaStore"; diff --git a/frontend-react/src/hooks/useSchemetaStore.ts b/frontend-react/src/hooks/useSchemetaStore.ts new file mode 100644 index 0000000..c797744 --- /dev/null +++ b/frontend-react/src/hooks/useSchemetaStore.ts @@ -0,0 +1,17 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { SchemetaStore, StoreState } from "../state"; + +type Selector = (state: StoreState) => TSelected; + +export function useSchemetaStoreState(store: SchemetaStore): StoreState { + return useSyncExternalStore(store.subscribe, store.getState, store.getState); +} + +export function useSchemetaSelector(store: SchemetaStore, selector: Selector): TSelected { + const state = useSchemetaStoreState(store); + return useMemo(() => selector(state), [selector, state]); +} + +export function useSchemetaActions(store: SchemetaStore): SchemetaStore["actions"] { + return store.actions; +} diff --git a/frontend-react/src/state/store.d.ts b/frontend-react/src/state/store.d.ts index 574df64..c12f6c1 100644 --- a/frontend-react/src/state/store.d.ts +++ b/frontend-react/src/state/store.d.ts @@ -23,6 +23,9 @@ export type SchemetaModel = { }; export type CompileResult = { + api_version?: string; + schema_version?: string; + request_id?: string; ok: boolean; errors: Array>; warnings: Array>; @@ -52,6 +55,12 @@ export type UiFlagsSlice = { jsonError: string | null; }; +export type LifecycleSlice = { + isCompiling: boolean; + lastError: string | null; + lastAction: string | null; +}; + export type StateSnapshot = { model: SchemetaModel | null; compileResult: CompileResult | null; @@ -66,6 +75,7 @@ export type StoreState = { selection: SelectionSlice; viewport: ViewportSlice; uiFlags: UiFlagsSlice; + lifecycle: LifecycleSlice; history: { past: Array<{ label: string; snapshot: StateSnapshot }>; future: Array<{ label: string; snapshot: StateSnapshot }>; @@ -89,6 +99,10 @@ export type SchemetaStore = { moveComponent(ref: string, placement: Partial): void; setViewport(viewport: Partial): void; setUiFlags(uiFlags: Partial): void; + setLifecycle(lifecycle: Partial): void; + beginCompile(action: string): void; + completeCompile(action: string): void; + failCompile(action: string, error: unknown): void; applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string }; beginTransaction(label?: string): boolean; commitTransaction(): boolean; diff --git a/frontend-react/src/state/store.js b/frontend-react/src/state/store.js index 19377a3..41cf5bd 100644 --- a/frontend-react/src/state/store.js +++ b/frontend-react/src/state/store.js @@ -29,6 +29,9 @@ const DEFAULT_HISTORY_LIMIT = 80; /** * @typedef {Object} CompileResult + * @property {string} [api_version] + * @property {string} [schema_version] + * @property {string} [request_id] * @property {boolean} ok * @property {Array>} errors * @property {Array>} warnings @@ -65,6 +68,13 @@ const DEFAULT_HISTORY_LIMIT = 80; * @property {string | null} jsonError */ +/** + * @typedef {Object} LifecycleSlice + * @property {boolean} isCompiling + * @property {string | null} lastError + * @property {string | null} lastAction + */ + /** * @typedef {Object} StateSnapshot * @property {SchemetaModel | null} model @@ -87,6 +97,7 @@ const DEFAULT_HISTORY_LIMIT = 80; * @property {SelectionSlice} selection * @property {ViewportSlice} viewport * @property {UiFlagsSlice} uiFlags + * @property {LifecycleSlice} lifecycle * @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history * @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction */ @@ -157,6 +168,25 @@ function normalizeUiFlags(uiFlags, prev) { }; } +/** + * @param {Partial} 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 * @returns {StateSnapshot} @@ -413,6 +443,18 @@ function applyJsonTextFailureState(state, text) { }; } +/** + * @param {StoreState} state + * @param {Partial} lifecycle + * @returns {StoreState} + */ +function setLifecycleState(state, lifecycle) { + return { + ...state, + lifecycle: normalizeLifecycle(lifecycle, state.lifecycle) + }; +} + /** * @param {Partial} [overrides] * @returns {StoreState} @@ -432,6 +474,11 @@ export function createInitialState(overrides = {}) { renderMode: "schematic_stub", jsonError: null }), + lifecycle: normalizeLifecycle(overrides.lifecycle ?? {}, { + isCompiling: false, + lastError: null, + lastAction: null + }), history: { past: [], future: [], @@ -508,6 +555,48 @@ export function createSchemetaStore(initialState = {}) { ); }, + /** @param {Partial} 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 */ applyJsonText(jsonText) { try { diff --git a/frontend-react/src/styles.css b/frontend-react/src/styles.css index 7a19460..d517402 100644 --- a/frontend-react/src/styles.css +++ b/frontend-react/src/styles.css @@ -43,6 +43,41 @@ body { 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 { display: flex; align-items: center; @@ -91,6 +126,7 @@ body { .panel { padding: 0.9rem; + overflow: auto; } .panel__title { @@ -103,20 +139,227 @@ body { color: #52626e; } -.canvas { - display: grid; - place-items: center; - background: radial-gradient(circle at 20% 20%, #eef5fb 0%, #ffffff 70%); +.panel input[type="search"] { + width: 100%; + margin-bottom: 0.7rem; + padding: 0.4rem 0.5rem; + border: 1px solid #9bb0c0; + border-radius: 6px; + font: inherit; } -.canvas__placeholder { - border: 1px dashed #91a6b7; +.panel h3 { + 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; - padding: 1.2rem 1.4rem; - color: #355066; + background: #f7fbff; + 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) { + .topbar { + align-items: flex-start; + } + + .topbar__meta { + align-items: flex-start; + } + .workspace-grid { grid-template-columns: 1fr; grid-template-rows: auto minmax(260px, 1fr) auto; diff --git a/tests/state-store.test.js b/tests/state-store.test.js index e917910..ca37266 100644 --- a/tests/state-store.test.js +++ b/tests/state-store.test.js @@ -120,3 +120,46 @@ test("transaction scaffold groups multiple actions into one undo step", () => { assert.equal(u1.placement.x, 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); +});