diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx index 1f1511d..b580ef5 100644 --- a/apps/web/app/editor/[projectId]/page.tsx +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -1,16 +1,10 @@ -import { Suspense } from "react"; import { EditorShell } from "../../../components/editor/EditorShell"; import { aristotleFixture } from "../../../lib/fixtures/aristotle"; // M1: every projectId resolves to the Aristotle fixture. // M4–M5 wire this to a real database lookup. -// -// Suspense boundary required because EditorShell uses useSearchParams (for -// the M4 ?break=... demo of validation rules). +// (EditorShell wraps itself in Suspense for useSearchParams; no boundary +// needed at this level.) export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) { - return ( - - - - ); + return ; } diff --git a/apps/web/components/diagram-canvas/DiagramCanvas.tsx b/apps/web/components/diagram-canvas/DiagramCanvas.tsx index ba37f62..e06e2bd 100644 --- a/apps/web/components/diagram-canvas/DiagramCanvas.tsx +++ b/apps/web/components/diagram-canvas/DiagramCanvas.tsx @@ -1,11 +1,18 @@ -// React Flow-backed diagram canvas (M3). -// - Custom block/actor/constraint/system nodes -// - Custom edges (association/composition/constraint) -// - Drag-create from the left palette -// - Click-edit via the right inspector -// - Drag handles between nodes to connect +// React Flow-backed diagram canvas (M5). // -// State is in-memory for M3; M5 swaps it for the bidirectional sync engine. +// State separation: +// - SysMLModel → ModelStore (canonical, semantic). The narrative reads here too. +// - React Flow → owns positions, selection, drag-in-progress (UI ephemeral). +// +// Sync rules: +// - On model change from outside (chip rename, etc.), we apply targeted +// `setNodes` updates (label, kind, properties) WITHOUT touching positions. +// Newly-added blocks/constraints get added; removed ones get removed. +// - On user actions in RF (connect, drop-from-palette, node delete, edge +// delete, inspector edit), we emit ModelOps via useApply(). +// +// This avoids the "re-derive nodes on every drag tick" trap that made +// dragging feel laggy in the first cut. "use client"; @@ -17,12 +24,11 @@ import { BackgroundVariant, Controls, MarkerType, - applyNodeChanges, - applyEdgeChanges, - addEdge, + useNodesState, + useEdgesState, useReactFlow, - type Node, type Edge, + type Node, type NodeChange, type EdgeChange, type Connection, @@ -32,15 +38,32 @@ import { BlockNode, type BlockNodeData } from "./nodes/BlockNode"; import { SysmlEdge, type SysmlEdgeData } from "./edges/SysmlEdge"; import { Palette } from "./Palette"; import { NodeInspector } from "./NodeInspector"; -import { fixtureToFlow } from "./fixtureToFlow"; -import type { Density } from "../socrates/SocratesDock"; -import type { FixtureData, AssociationKind, BlockKind } from "../../lib/fixtures/aristotle"; +import { useModel, useApply } from "../../lib/sync/ModelStore"; +import { + addBlock as addBlockOp, + addConstraint as addConstraintOp, + removeBlock as removeBlockOp, + removeConstraint as removeConstraintOp, + removeAssociation as removeAssociationOp, + updateBlock as updateBlockOp, + updateConstraint as updateConstraintOp, + addAssociation as addAssociationOp, + addProperty as addPropertyOp, + updateProperty as updatePropertyOp, + removeProperty as removePropertyOp, + newProperty, + tempId, + type ModelOp, +} from "../../lib/sync/ops"; import type { ValidationIssue } from "../../lib/sysml/validate"; +import type { Density } from "../socrates/SocratesDock"; +import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle"; +import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model"; export type DiagramVariant = "softened" | "formal" | "graph"; interface DiagramCanvasProps { - data: FixtureData; + data?: FixtureData; density?: Density; variant?: DiagramVariant; focusBlockId: string | null; @@ -51,12 +74,11 @@ interface DiagramCanvasProps { const nodeTypes = { sysmlBlock: BlockNode }; const edgeTypes = { sysml: SysmlEdge }; -let nextNodeId = 1000; -let nextEdgeId = 1000; -function freshNodeId(): string { return `n${nextNodeId++}`; } -function freshEdgeId(): string { return `e${nextEdgeId++}`; } +const BOARD_W = 720; +const BOARD_H = 460; -function pickWorst(issues: ValidationIssue[]): "error" | "warning" | "soft" | undefined { +function pickWorst(issues: ValidationIssue[] | undefined): "error" | "warning" | "soft" | undefined { + if (!issues) return undefined; if (issues.some(i => i.severity === "error")) return "error"; if (issues.some(i => i.severity === "warning")) return "warning"; if (issues.some(i => i.severity === "soft")) return "soft"; @@ -72,60 +94,195 @@ export function DiagramCanvas(props: DiagramCanvasProps) { } function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) { - const initial = useMemo(() => fixtureToFlow(data), [data]); - const [nodes, setNodes] = useState[]>(initial.nodes); - const [edges, setEdges] = useState[]>(initial.edges); + const model = useModel(); + const apply = useApply(); const wrapperRef = useRef(null); const { screenToFlowPosition } = useReactFlow(); - const onNodesChange = useCallback( - (changes: NodeChange[]) => setNodes(ns => applyNodeChanges(changes, ns) as Node[]), - [] - ); - const onEdgesChange = useCallback( - (changes: EdgeChange[]) => setEdges(es => applyEdgeChanges(changes, es) as Edge[]), - [] - ); - const onConnect = useCallback((connection: Connection) => { - const newEdge: Edge = { - id: freshEdgeId(), - source: connection.source, - target: connection.target, - type: "sysml", - data: { label: "relates_to", kind: "association" satisfies AssociationKind }, - markerEnd: { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 }, - }; - setEdges(es => addEdge(newEdge, es) as Edge[]); - }, []); + // Initialize React Flow state from the model + fixture positions on first render. + // Lazy `useState`-style init to avoid recomputing on every render. + const [initialN] = useState(() => initialNodes(model, data)); + const [initialE] = useState(() => initialEdges(model)); + const [nodes, setNodes, onNodesChangeRaw] = useNodesState>(initialN); + const [edges, setEdges, onEdgesChangeRaw] = useEdgesState>(initialE); - // Highlight focusBlockId from sibling components (e.g. narrative chip hover). - // Also surface validation severity onto each node so BlockNode can ring it. + // Track the model's element ids so we can detect adds/removes between renders. + const elementIdsRef = useRef<{ blocks: Set; assocs: Set; constraints: Set }>(snapshotIds(model)); + + // ─── Model → React Flow (targeted sync) ────────────────────────────────── useEffect(() => { - setNodes(ns => - ns.map(n => { - const issues = issuesByElement?.get(n.id) ?? []; - const severity = pickWorst(issues); - return { - ...n, - selected: n.id === focusBlockId, - data: { ...(n.data as BlockNodeData), issueSeverity: severity }, - }; - }) - ); - }, [focusBlockId, issuesByElement]); + const prev = elementIdsRef.current; + const curr = snapshotIds(model); + + setNodes(currentNodes => { + const byId = new Map(currentNodes.map(n => [n.id, n])); + const next: Node[] = []; + + // Existing model elements first — preserve position & selection from RF state + for (const b of model.blocks) { + const existing = byId.get(b.id); + if (existing) { + // Update data only if it changed (cheap structural compare) + const propNames = b.properties.map(p => p.name); + const dataChanged = + existing.data?.label !== b.label || + existing.data?.kind !== b.kind || + !sameStringArray(existing.data?.properties ?? [], propNames); + if (dataChanged) { + next.push({ ...existing, data: { ...existing.data, label: b.label, kind: b.kind, properties: propNames } }); + } else { + next.push(existing); + } + } else { + // New block from outside (rare in M5; mostly self-originated drops) + next.push(makeNodeForBlock(b)); + } + } + for (const c of model.constraints) { + const existing = byId.get(c.id); + if (existing) { + const expr = c.expression || "{ }"; + const dataChanged = + existing.data?.label !== c.label || + existing.data?.expression !== expr; + if (dataChanged) { + next.push({ ...existing, data: { ...existing.data, label: c.label, kind: "constraint", properties: [], expression: expr } }); + } else { + next.push(existing); + } + } else { + next.push(makeNodeForConstraint(c)); + } + } + return next; + }); + + setEdges(currentEdges => { + const byId = new Map(currentEdges.map(e => [e.id, e])); + const next: Edge[] = []; + + for (const a of model.associations) { + const existing = byId.get(a.id); + if (existing && existing.data?.label === a.label && existing.data?.kind === a.kind) { + next.push(existing); + } else { + next.push(makeEdgeForAssociation(a)); + } + } + // Synthesized constraint→block edges + for (const c of model.constraints) { + for (const target of c.appliesTo) { + const id = `${c.id}__applies__${target}`; + const existing = byId.get(id); + next.push(existing ?? makeEdgeForApplies(c.id, target)); + } + } + return next; + }); + + elementIdsRef.current = curr; + void prev; + }, [model, setNodes, setEdges]); + + // ─── Selection sync — narrative chip hover / rail entry hover → here ──── + useEffect(() => { + setNodes(curr => { + let changed = false; + const next = curr.map(n => { + const shouldSelect = n.id === focusBlockId; + if (n.selected === shouldSelect) return n; + changed = true; + return { ...n, selected: shouldSelect }; + }); + return changed ? next : curr; + }); + }, [focusBlockId, setNodes]); + + // ─── Issue-severity sync — validation results → node ring color ───────── + useEffect(() => { + setNodes(curr => { + let changed = false; + const next = curr.map(n => { + const next = pickWorst(issuesByElement?.get(n.id)); + if (n.data?.issueSeverity === next) return n; + changed = true; + return { ...n, data: { ...n.data, issueSeverity: next } }; + }); + return changed ? next : curr; + }); + }, [issuesByElement, setNodes]); + + // ─── React Flow → ModelStore handlers ─────────────────────────────────── + + const onNodesChange = useCallback((changes: NodeChange[]) => { + // 1. Let React Flow update its own state (positions, drags, selections) + onNodesChangeRaw(changes as NodeChange>[]); + + // 2. For removals, emit the corresponding remove ops + const removalOps: ModelOp[] = []; + for (const c of changes) { + if (c.type !== "remove") continue; + const isConstraint = !!model.constraints.find(x => x.id === c.id); + if (isConstraint) { + removalOps.push(removeConstraintOp(c.id)); + } else if (model.blocks.find(x => x.id === c.id)) { + removalOps.push(removeBlockOp(c.id)); + } + } + if (removalOps.length > 0) apply(removalOps); + }, [apply, model.blocks, model.constraints, onNodesChangeRaw]); + + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + // 1. Let React Flow update its own state + onEdgesChangeRaw(changes as EdgeChange>[]); + + // 2. For removals, emit ops — except for synthesized constraint-applies + // edges which decompose into a constraint update. + const removalOps: ModelOp[] = []; + for (const c of changes) { + if (c.type !== "remove") continue; + + if (c.id.includes("__applies__")) { + const [cid, tid] = c.id.split("__applies__"); + const target = model.constraints.find(x => x.id === cid); + if (target) { + removalOps.push(updateConstraintOp(cid!, { appliesTo: target.appliesTo.filter(x => x !== tid) })); + } + } else if (model.associations.find(a => a.id === c.id)) { + removalOps.push(removeAssociationOp(c.id)); + } + } + if (removalOps.length > 0) apply(removalOps); + }, [apply, model.associations, model.constraints, onEdgesChangeRaw]); + + const onConnect = useCallback((connection: Connection) => { + if (!connection.source || !connection.target) return; + const newId = tempId("a"); + apply([ + addAssociationOp({ + id: newId, + fromBlockId: connection.source, + toBlockId: connection.target, + label: "relates_to", + kind: "association", + }, newId), + ]); + }, [apply]); const onNodeClick: NodeMouseHandler = useCallback( - (_event, node) => { - onSelect?.(node.id); - }, + (_event, node) => onSelect?.(node.id), [onSelect] ); + const onPaneClick = useCallback(() => onSelect?.(null), [onSelect]); - const onPaneClick = useCallback(() => { - onSelect?.(null); - }, [onSelect]); - - const selectedNode = nodes.find(n => n.id === focusBlockId); + const selectedBlock = useMemo( + () => model.blocks.find(b => b.id === focusBlockId) ?? null, + [model.blocks, focusBlockId] + ); + const selectedConstraint = useMemo( + () => model.constraints.find(c => c.id === focusBlockId) ?? null, + [model.constraints, focusBlockId] + ); // Drag-and-drop from the palette const onDragOver = useCallback((event: React.DragEvent) => { @@ -138,46 +295,94 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram event.preventDefault(); const kindRaw = event.dataTransfer.getData("application/sysml-kind"); if (!kindRaw) return; - const kind = kindRaw as BlockKind | "system"; const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); - const id = freshNodeId(); - const newNode: Node = { - id, - type: "sysmlBlock", - position, - data: { - label: kind === "system" - ? "System" - : kind === "actor" - ? "Actor" - : kind === "constraint" - ? "Constraint" - : "Block", - kind, - properties: kind === "constraint" ? [] : ["new_property"], - ...(kind === "constraint" ? { expression: "{ }" } : {}), - }, + + if (kindRaw === "constraint") { + const cid = tempId("c"); + const result = apply([ + addConstraintOp({ id: cid, label: "Constraint", expression: "{ }", appliesTo: [] }, cid), + ]); + if (result.applied) { + const final = result.idMapping[cid] ?? cid; + // Position the new node at the drop coordinates + setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n))); + onSelect?.(final); + } + return; + } + + const kind = kindRaw as BlockKind | "system"; + const bid = tempId("b"); + const block: Block = { + id: bid, + label: + kind === "system" ? "System" : + kind === "actor" ? "Actor" : + "Block", + kind: kind === "system" ? "system" : kind, + stereotypes: [kind], + properties: [newProperty("new_property")], }; - setNodes(ns => [...ns, newNode]); - onSelect?.(id); + const result = apply([addBlockOp(block, bid)]); + if (result.applied) { + const final = result.idMapping[bid] ?? bid; + setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n))); + onSelect?.(final); + } }, - [onSelect, screenToFlowPosition] + [apply, onSelect, screenToFlowPosition, setNodes] ); function patchSelected(patch: Partial) { - if (!focusBlockId) return; - setNodes(ns => - ns.map(n => (n.id === focusBlockId ? { ...n, data: { ...(n.data as BlockNodeData), ...patch } } : n)) - ); + if (selectedBlock) { + const ops: ModelOp[] = []; + if (patch.label !== undefined && patch.label !== selectedBlock.label) { + ops.push(updateBlockOp(selectedBlock.id, { label: patch.label })); + } + if (patch.kind !== undefined && patch.kind !== selectedBlock.kind) { + ops.push(updateBlockOp(selectedBlock.id, { kind: patch.kind, stereotypes: [patch.kind] })); + } + if (patch.properties !== undefined) { + ops.push(...diffProperties(selectedBlock, patch.properties)); + } + if (ops.length > 0) apply(ops); + } else if (selectedConstraint) { + const ops: ModelOp[] = []; + if (patch.label !== undefined && patch.label !== selectedConstraint.label) { + ops.push(updateConstraintOp(selectedConstraint.id, { label: patch.label })); + } + if (patch.expression !== undefined && patch.expression !== selectedConstraint.expression) { + ops.push(updateConstraintOp(selectedConstraint.id, { expression: patch.expression })); + } + if (ops.length > 0) apply(ops); + } } function deleteSelected() { - if (!focusBlockId) return; - setNodes(ns => ns.filter(n => n.id !== focusBlockId)); - setEdges(es => es.filter(e => e.source !== focusBlockId && e.target !== focusBlockId)); - onSelect?.(null); + if (selectedBlock) { + apply([removeBlockOp(selectedBlock.id)]); + onSelect?.(null); + } else if (selectedConstraint) { + apply([removeConstraintOp(selectedConstraint.id)]); + onSelect?.(null); + } } + const inspectorData: BlockNodeData | null = selectedBlock + ? { + label: selectedBlock.label, + kind: selectedBlock.kind, + properties: selectedBlock.properties.map(p => p.name), + } + : selectedConstraint + ? { + label: selectedConstraint.label, + kind: "constraint", + properties: [], + expression: selectedConstraint.expression, + } + : null; + return (
- {selectedNode && ( + {inspectorData && focusBlockId && ( onSelect?.(null)} @@ -216,3 +422,137 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
); } + +// ─── Initial state builders ────────────────────────────────────────────── + +function initialNodes(model: SysMLModel, data?: FixtureData): Node[] { + // Bootstrap positions from the fixture for the original Aristotle blocks. + const fixturePositions: Record = {}; + if (data) { + for (const b of data.blocks) { + fixturePositions[b.id] = { x: b.x * BOARD_W, y: b.y * BOARD_H }; + } + } + const nodes: Node[] = []; + let autoCol = 0; + let autoRow = 0; + function autoPosition() { + const pos = { x: 60 + autoCol * 220, y: 60 + autoRow * 160 }; + autoCol++; + if (autoCol >= 4) { autoCol = 0; autoRow++; } + return pos; + } + + for (const b of model.blocks) { + nodes.push({ + ...makeNodeForBlock(b), + position: fixturePositions[b.id] ?? autoPosition(), + }); + } + for (const c of model.constraints) { + nodes.push({ + ...makeNodeForConstraint(c), + position: fixturePositions[c.id] ?? autoPosition(), + }); + } + return nodes; +} + +function initialEdges(model: SysMLModel): Edge[] { + const edges: Edge[] = []; + for (const a of model.associations) edges.push(makeEdgeForAssociation(a)); + for (const c of model.constraints) { + for (const target of c.appliesTo) { + edges.push(makeEdgeForApplies(c.id, target)); + } + } + return edges; +} + +function makeNodeForBlock(b: Block): Node { + return { + id: b.id, + type: "sysmlBlock", + position: { x: 0, y: 0 }, + data: { + label: b.label, + kind: b.kind, + properties: b.properties.map(p => p.name), + }, + }; +} + +function makeNodeForConstraint(c: import("../../lib/sysml/model").Constraint): Node { + return { + id: c.id, + type: "sysmlBlock", + position: { x: 0, y: 0 }, + data: { + label: c.label, + kind: "constraint", + properties: [], + expression: c.expression || "{ }", + }, + }; +} + +function makeEdgeForAssociation(a: import("../../lib/sysml/model").Association): Edge { + return { + id: a.id, + source: a.fromBlockId, + target: a.toBlockId, + type: "sysml", + data: { label: a.label, kind: a.kind }, + markerEnd: a.kind === "composition" + ? { type: MarkerType.ArrowClosed, color: "var(--edge)", width: 18, height: 18 } + : a.kind === "constraintApplies" + ? undefined + : { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 }, + }; +} + +function makeEdgeForApplies(constraintId: string, targetBlockId: string): Edge { + return { + id: `${constraintId}__applies__${targetBlockId}`, + source: constraintId, + target: targetBlockId, + type: "sysml", + data: { label: "applies_to", kind: "constraintApplies" }, + }; +} + +function snapshotIds(model: SysMLModel): { blocks: Set; assocs: Set; constraints: Set } { + return { + blocks: new Set(model.blocks.map(b => b.id)), + assocs: new Set(model.associations.map(a => a.id)), + constraints: new Set(model.constraints.map(c => c.id)), + }; +} + +function sameStringArray(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** Diff property-name lists into add/update/remove ops. */ +function diffProperties(block: Block, nextNames: string[]): ModelOp[] { + const ops: ModelOp[] = []; + const cur = block.properties; + for (let i = 0; i < cur.length; i++) { + const c = cur[i]!; + const n = nextNames[i]; + if (n === undefined) { + ops.push(removePropertyOp(block.id, c.id)); + } else if (n !== c.name) { + ops.push(updatePropertyOp(block.id, c.id, { name: n })); + } + } + for (let i = cur.length; i < nextNames.length; i++) { + const n = nextNames[i]!; + const pid = tempId("p"); + const p: Property = { id: pid, name: n, type: { kind: "string" } as PropertyType, multiplicity: "0..1" }; + ops.push(addPropertyOp(block.id, p, pid)); + } + return ops; +} diff --git a/apps/web/components/diagram-canvas/edges/SysmlEdge.tsx b/apps/web/components/diagram-canvas/edges/SysmlEdge.tsx index e5b8068..c46ab50 100644 --- a/apps/web/components/diagram-canvas/edges/SysmlEdge.tsx +++ b/apps/web/components/diagram-canvas/edges/SysmlEdge.tsx @@ -6,7 +6,7 @@ "use client"; import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from "@xyflow/react"; -import type { AssociationKind } from "../../../lib/fixtures/aristotle"; +import type { AssociationKind } from "../../../lib/sysml/model"; export interface SysmlEdgeData extends Record { label?: string; @@ -26,7 +26,7 @@ export function SysmlEdge(props: EdgeProps) { curvature: 0.25, }); - const isConstraint = kind === "constraint"; + const isConstraint = kind === "constraintApplies"; return ( <> diff --git a/apps/web/components/diagram-canvas/fixtureToFlow.ts b/apps/web/components/diagram-canvas/fixtureToFlow.ts deleted file mode 100644 index 58ad9b8..0000000 --- a/apps/web/components/diagram-canvas/fixtureToFlow.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Convert FixtureData → React Flow nodes/edges. -// Positions in the fixture are normalized 0..1 against a 720×460 board; we map -// to absolute pixel coordinates so React Flow can render them. - -import { MarkerType, type Edge, type Node } from "@xyflow/react"; -import type { BlockNodeData } from "./nodes/BlockNode"; -import type { SysmlEdgeData } from "./edges/SysmlEdge"; -import type { FixtureData, AssociationKind } from "../../lib/fixtures/aristotle"; - -const BOARD_W = 720; -const BOARD_H = 460; - -function markerForKind(kind: AssociationKind) { - if (kind === "composition") { - // React Flow doesn't ship a diamond marker; use the closed-arrow as the - // closest stock alternative. M3 ships this — a custom diamond can be - // added later if needed. - return { type: MarkerType.ArrowClosed, color: "var(--edge)", width: 18, height: 18 }; - } - if (kind === "constraint") return undefined; - return { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 }; -} - -export function fixtureToFlow(data: FixtureData): { nodes: Node[]; edges: Edge[] } { - const nodes: Node[] = data.blocks.map(b => ({ - id: b.id, - type: "sysmlBlock", - position: { x: b.x * BOARD_W, y: b.y * BOARD_H }, - data: { - label: b.label, - kind: b.kind, - properties: [...b.properties], - ...(b.kind === "constraint" ? { expression: "{ tenancy = institutional }" } : {}), - }, - })); - - const edges: Edge[] = data.associations.map(a => ({ - id: a.id, - source: a.from, - target: a.to, - type: "sysml", - data: { label: a.label, kind: a.kind }, - markerEnd: markerForKind(a.kind), - })); - - return { nodes, edges }; -} diff --git a/apps/web/components/diagram-canvas/modelToFlow.ts b/apps/web/components/diagram-canvas/modelToFlow.ts new file mode 100644 index 0000000..1eebf69 --- /dev/null +++ b/apps/web/components/diagram-canvas/modelToFlow.ts @@ -0,0 +1,112 @@ +// SysMLModel → React Flow nodes/edges. M5 replaces fixtureToFlow with this +// so the diagram renders the canonical store state. + +import { MarkerType, type Edge, type Node } from "@xyflow/react"; +import type { SysMLModel, AssociationKind } from "../../lib/sysml/model"; +import type { BlockNodeData } from "./nodes/BlockNode"; +import type { SysmlEdgeData } from "./edges/SysmlEdge"; + +// Default board dimensions used when the model lacks per-block positions. +const BOARD_W = 720; +const BOARD_H = 460; + +export interface NodePositions { + [blockId: string]: { x: number; y: number }; +} + +export interface ConvertOptions { + /** Per-node positions tracked in component state (drag offsets). */ + positions?: NodePositions; +} + +function markerForKind(kind: AssociationKind) { + if (kind === "composition") { + return { type: MarkerType.ArrowClosed, color: "var(--edge)", width: 18, height: 18 }; + } + if (kind === "constraintApplies") return undefined; + return { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 }; +} + +export function modelToFlow(model: SysMLModel, opts: ConvertOptions = {}): { nodes: Node[]; edges: Edge[] } { + const positions = opts.positions ?? {}; + + // Auto-place blocks that don't have a tracked position. Spread them on a + // grid so newly-created elements don't all stack at (0,0). + const positioned = new Set(Object.keys(positions)); + let autoIdx = 0; + function autoPosition(blockId: string) { + autoIdx++; + const col = autoIdx % 4; + const row = Math.floor(autoIdx / 4); + return { x: 100 + col * 220, y: 100 + row * 160 }; + } + + // Render constraints + blocks together so the user sees both. + const allBlockLikeNodes: Node[] = []; + + model.blocks.forEach((b, i) => { + const pos = positions[b.id] ?? defaultPositionForIndex(i, model.blocks.length); + allBlockLikeNodes.push({ + id: b.id, + type: "sysmlBlock", + position: pos, + data: { + label: b.label, + kind: b.kind, + properties: b.properties.map(p => p.name), + }, + }); + }); + + model.constraints.forEach(c => { + const pos = positions[c.id] ?? autoPosition(c.id); + if (!positioned.has(c.id)) positioned.add(c.id); + allBlockLikeNodes.push({ + id: c.id, + type: "sysmlBlock", + position: pos, + data: { + label: c.label, + kind: "constraint", + properties: [], + expression: c.expression || "{ }", + }, + }); + }); + + const edges: Edge[] = []; + + for (const a of model.associations) { + edges.push({ + id: a.id, + source: a.fromBlockId, + target: a.toBlockId, + type: "sysml", + data: { label: a.label, kind: a.kind }, + markerEnd: markerForKind(a.kind), + }); + } + + // Synthesize constraint→block edges from the constraint's appliesTo field. + for (const c of model.constraints) { + for (const target of c.appliesTo) { + edges.push({ + id: `${c.id}__applies__${target}`, + source: c.id, + target, + type: "sysml", + data: { label: "applies_to", kind: "constraintApplies" }, + }); + } + } + + return { nodes: allBlockLikeNodes, edges }; +} + +function defaultPositionForIndex(i: number, total: number): { x: number; y: number } { + // Best-effort grid layout when no positions are tracked yet. + const cols = Math.max(2, Math.ceil(Math.sqrt(total))); + const col = i % cols; + const row = Math.floor(i / cols); + return { x: 60 + col * (BOARD_W / cols), y: 60 + row * 160 }; +} diff --git a/apps/web/components/editor/EditorShell.tsx b/apps/web/components/editor/EditorShell.tsx index 4b872ab..12318a6 100644 --- a/apps/web/components/editor/EditorShell.tsx +++ b/apps/web/components/editor/EditorShell.tsx @@ -1,11 +1,10 @@ // The dual-canvas workspace shell. -// Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar. -// M4 adds: SysML model derivation + validation, IssuesPanel, optional ?break=... -// query param to demonstrate the validator surfacing rule violations. +// M5: state lives in ModelStoreProvider; both canvases consume the canonical +// SysMLModel and emit ModelOps back through useApply(). "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { TopBar } from "./TopBar"; import { LeftRail } from "./LeftRail"; @@ -18,8 +17,8 @@ import { SocratesDock, type Density, type SocratesPresence } from "../socrates/S import type { MarkupStyle } from "../text-canvas/Chip"; import type { FixtureData } from "../../lib/fixtures/aristotle"; import { fromFixture } from "../../lib/sysml/fromFixture"; -import { validate, type ValidationIssue } from "../../lib/sysml/validate"; import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks"; +import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore"; interface EditorShellProps { data: FixtureData; @@ -29,42 +28,62 @@ interface EditorShellProps { presence?: SocratesPresence; } -export function EditorShell({ +export function EditorShell(props: EditorShellProps) { + return ( + + + + ); +} + +function EditorShellInner({ data, density = "comfortable", markupStyle = "color", diagramStyle = "softened", presence = "default", }: EditorShellProps) { - const [focusBlockId, setFocusBlockId] = useState(null); const searchParams = useSearchParams(); - // Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions. const breaks = useMemo(() => { const raw = searchParams?.get("break") ?? ""; if (!raw) return []; - return raw - .split(",") - .map(s => s.trim()) - .filter((s): s is BreakName => s in BREAKS); + return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS); }, [searchParams]); - // Derive the SysML model from the fixture, optionally apply breaks, validate. - const { issues, issuesByElement } = useMemo(() => { - const baseModel = fromFixture(data); - const broken = breaks.length > 0 ? applyBreaks(baseModel, breaks) : baseModel; - const issues = validate(broken); - const issuesByElement = new Map(); - for (const i of issues) { - const key = anchorKey(i.anchor); - if (!key) continue; - const prev = issuesByElement.get(key) ?? []; - prev.push(i); - issuesByElement.set(key, prev); - } - return { issues, issuesByElement }; + const initialModel = useMemo(() => { + const base = fromFixture(data); + return breaks.length > 0 ? applyBreaks(base, breaks) : base; }, [data, breaks]); + return ( + + + + ); +} + +function ShellBody({ + data, + density, + markupStyle, + diagramStyle, + presence, + breaks, +}: Required> & { data: FixtureData; breaks: BreakName[] }) { + const [focusBlockId, setFocusBlockId] = useState(null); + const { model, issues, issuesByElement } = useModelStore(); + + const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`; + const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats; + return (
@@ -97,9 +116,7 @@ export function EditorShell({
0 - ? `SysML · breaks active: ${breaks.join(", ")}` - : "SysML · 6 blocks · 6 associations · 1 constraint"} + subtitle={subtitle} right={
Fit @@ -128,17 +145,3 @@ export function EditorShell({
); } - -/** Map an issue's anchor to a single string key matching either a block id or - * a synthetic key (`assoc:a1`, `req:req-001`, `constraint:ferpa`). The UI uses - * block ids most often, so block anchors return the bare id. */ -function anchorKey(anchor: ValidationIssue["anchor"]): string | null { - switch (anchor.kind) { - case "block": return anchor.id; - case "association": return `assoc:${anchor.id}`; - case "constraint": return `constraint:${anchor.id}`; - case "requirement": return `req:${anchor.id}`; - case "property": return anchor.blockId; - case "model": return null; - } -} diff --git a/apps/web/components/editor/LeftRail.tsx b/apps/web/components/editor/LeftRail.tsx index b905c50..f3c56af 100644 --- a/apps/web/components/editor/LeftRail.tsx +++ b/apps/web/components/editor/LeftRail.tsx @@ -1,12 +1,17 @@ // Outline / Model / Requirements sections, each independently collapsible. // The whole rail can also collapse to a 36px vertical strip. -// Ported from docs/design-source/socrata/project/editor-shell.jsx (LeftRail). +// +// M5: Model + Requirements sections read from the canonical SysMLModel via +// useModelStore() so renames in either canvas reflect here immediately. +// Outline section is still narrative-derived and uses the fixture (M6 will +// migrate it to the live ProseMirror outline). "use client"; import { useState } from "react"; import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { ValidationIssue, Severity } from "../../lib/sysml/validate"; +import { useModel } from "../../lib/sync/ModelStore"; interface LeftRailProps { data: FixtureData; @@ -29,10 +34,11 @@ function IssueDot({ severity, title }: { severity: Severity | null; title?: stri return ; } -export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) { +export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) { const [collapsed, setCollapsed] = useState(false); const [open, setOpen] = useState({ outline: true, model: true, requirements: true }); const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] })); + const model = useModel(); if (collapsed) { return ( @@ -47,13 +53,39 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
OUT - MOD + MOD REQ
); } + // Combine blocks + constraints in the Model section, sorted by kind for a + // predictable order: system → block → actor → constraint. + const kindRank: Record = { system: 0, block: 1, actor: 2, constraint: 3 }; + const modelEntries: Array<{ + id: string; + label: string; + kind: "block" | "actor" | "constraint" | "system"; + propertyCount: number; + }> = [ + ...model.blocks.map(b => ({ + id: b.id, + label: b.label, + kind: b.kind, + propertyCount: b.properties.length, + })), + ...model.constraints.map(c => ({ + id: c.id, + label: c.label, + kind: "constraint" as const, + propertyCount: 0, + })), + ].sort((a, b) => { + const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99); + return r !== 0 ? r : a.label.localeCompare(b.label); + }); + return (