MVP M5 (in-memory): bidirectional sync via ModelOp + applyOps + ModelStore

Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.

apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
  association / constraint / requirement / relation, add/update/remove for
  each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
  reducer. Atomic per batch. Cascades on remove-block (drops incident
  associations + constraint applies-to + requirement satisfiers). tempId
  resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
  issuesByElement }. Validation memoized on every model change.

Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
  fixture (+ optional ?break= corruptions). Validation now lives in the
  store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
  constraints (sorted by kind), Requirements section lists real
  requirements with traced/untraced status from r.relations.

Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
  Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
  updates only for elements whose semantic data (label, kind, properties)
  changed. Object identity preserved for unchanged nodes — fixes the
  "re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
  ops via useApply(). Constraint-applies edges decompose into
  updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
  selecting a block + Del removed only the edges, leaving the block).

Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
  (block / requirement / association / property). Double-click chip →
  inline rename input → emit update-{block,requirement,association} op.
  All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
  affordance (until M6 wires real model element resolution).

Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
This commit is contained in:
2026-04-29 00:42:18 +02:00
parent 2052a280b1
commit 384cbb4ae9
12 changed files with 1375 additions and 229 deletions

View File

@@ -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<Node<BlockNodeData>[]>(initial.nodes);
const [edges, setEdges] = useState<Edge<SysmlEdgeData>[]>(initial.edges);
const model = useModel();
const apply = useApply();
const wrapperRef = useRef<HTMLDivElement | null>(null);
const { screenToFlowPosition } = useReactFlow();
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes(ns => applyNodeChanges(changes, ns) as Node<BlockNodeData>[]),
[]
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges(es => applyEdgeChanges(changes, es) as Edge<SysmlEdgeData>[]),
[]
);
const onConnect = useCallback((connection: Connection) => {
const newEdge: Edge<SysmlEdgeData> = {
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<SysmlEdgeData>[]);
}, []);
// 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<Node<BlockNodeData>>(initialN);
const [edges, setEdges, onEdgesChangeRaw] = useEdgesState<Edge<SysmlEdgeData>>(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<string>; assocs: Set<string>; constraints: Set<string> }>(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<BlockNodeData>[] = [];
// 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<SysmlEdgeData>[] = [];
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<Node<BlockNodeData>>[]);
// 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<Edge<SysmlEdgeData>>[]);
// 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<BlockNodeData> = {
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<BlockNodeData>) {
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 (
<div ref={wrapperRef} className="diagram-flow" onDragOver={onDragOver} onDrop={onDrop}>
<ReactFlow
@@ -190,6 +395,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
deleteKeyCode={["Backspace", "Delete"]}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false, maxZoom: 1.2 }}
proOptions={{ hideAttribution: true }}
@@ -204,10 +410,10 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
<Palette />
{selectedNode && (
{inspectorData && focusBlockId && (
<NodeInspector
nodeId={selectedNode.id}
data={selectedNode.data as BlockNodeData}
nodeId={focusBlockId}
data={inspectorData}
onChange={patchSelected}
onDelete={deleteSelected}
onClose={() => onSelect?.(null)}
@@ -216,3 +422,137 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
</div>
);
}
// ─── Initial state builders ──────────────────────────────────────────────
function initialNodes(model: SysMLModel, data?: FixtureData): Node<BlockNodeData>[] {
// Bootstrap positions from the fixture for the original Aristotle blocks.
const fixturePositions: Record<string, { x: number; y: number }> = {};
if (data) {
for (const b of data.blocks) {
fixturePositions[b.id] = { x: b.x * BOARD_W, y: b.y * BOARD_H };
}
}
const nodes: Node<BlockNodeData>[] = [];
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<SysmlEdgeData>[] {
const edges: Edge<SysmlEdgeData>[] = [];
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<BlockNodeData> {
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<BlockNodeData> {
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<SysmlEdgeData> {
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<SysmlEdgeData> {
return {
id: `${constraintId}__applies__${targetBlockId}`,
source: constraintId,
target: targetBlockId,
type: "sysml",
data: { label: "applies_to", kind: "constraintApplies" },
};
}
function snapshotIds(model: SysMLModel): { blocks: Set<string>; assocs: Set<string>; constraints: Set<string> } {
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;
}

View File

@@ -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<string, unknown> {
label?: string;
@@ -26,7 +26,7 @@ export function SysmlEdge(props: EdgeProps) {
curvature: 0.25,
});
const isConstraint = kind === "constraint";
const isConstraint = kind === "constraintApplies";
return (
<>

View File

@@ -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<BlockNodeData>[]; edges: Edge<SysmlEdgeData>[] } {
const nodes: Node<BlockNodeData>[] = 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<SysmlEdgeData>[] = 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 };
}

View File

@@ -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<BlockNodeData>[]; edges: Edge<SysmlEdgeData>[] } {
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<BlockNodeData>[] = [];
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<SysmlEdgeData>[] = [];
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 };
}