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,16 +1,10 @@
import { Suspense } from "react";
import { EditorShell } from "../../../components/editor/EditorShell"; import { EditorShell } from "../../../components/editor/EditorShell";
import { aristotleFixture } from "../../../lib/fixtures/aristotle"; import { aristotleFixture } from "../../../lib/fixtures/aristotle";
// M1: every projectId resolves to the Aristotle fixture. // M1: every projectId resolves to the Aristotle fixture.
// M4M5 wire this to a real database lookup. // M4M5 wire this to a real database lookup.
// // (EditorShell wraps itself in Suspense for useSearchParams; no boundary
// Suspense boundary required because EditorShell uses useSearchParams (for // needed at this level.)
// the M4 ?break=... demo of validation rules).
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) { export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
return ( return <EditorShell data={aristotleFixture} />;
<Suspense fallback={null}>
<EditorShell data={aristotleFixture} />
</Suspense>
);
} }

View File

@@ -1,11 +1,18 @@
// React Flow-backed diagram canvas (M3). // React Flow-backed diagram canvas (M5).
// - 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
// //
// 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"; "use client";
@@ -17,12 +24,11 @@ import {
BackgroundVariant, BackgroundVariant,
Controls, Controls,
MarkerType, MarkerType,
applyNodeChanges, useNodesState,
applyEdgeChanges, useEdgesState,
addEdge,
useReactFlow, useReactFlow,
type Node,
type Edge, type Edge,
type Node,
type NodeChange, type NodeChange,
type EdgeChange, type EdgeChange,
type Connection, type Connection,
@@ -32,15 +38,32 @@ import { BlockNode, type BlockNodeData } from "./nodes/BlockNode";
import { SysmlEdge, type SysmlEdgeData } from "./edges/SysmlEdge"; import { SysmlEdge, type SysmlEdgeData } from "./edges/SysmlEdge";
import { Palette } from "./Palette"; import { Palette } from "./Palette";
import { NodeInspector } from "./NodeInspector"; import { NodeInspector } from "./NodeInspector";
import { fixtureToFlow } from "./fixtureToFlow"; import { useModel, useApply } from "../../lib/sync/ModelStore";
import type { Density } from "../socrates/SocratesDock"; import {
import type { FixtureData, AssociationKind, BlockKind } from "../../lib/fixtures/aristotle"; 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 { 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"; export type DiagramVariant = "softened" | "formal" | "graph";
interface DiagramCanvasProps { interface DiagramCanvasProps {
data: FixtureData; data?: FixtureData;
density?: Density; density?: Density;
variant?: DiagramVariant; variant?: DiagramVariant;
focusBlockId: string | null; focusBlockId: string | null;
@@ -51,12 +74,11 @@ interface DiagramCanvasProps {
const nodeTypes = { sysmlBlock: BlockNode }; const nodeTypes = { sysmlBlock: BlockNode };
const edgeTypes = { sysml: SysmlEdge }; const edgeTypes = { sysml: SysmlEdge };
let nextNodeId = 1000; const BOARD_W = 720;
let nextEdgeId = 1000; const BOARD_H = 460;
function freshNodeId(): string { return `n${nextNodeId++}`; }
function freshEdgeId(): string { return `e${nextEdgeId++}`; }
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 === "error")) return "error";
if (issues.some(i => i.severity === "warning")) return "warning"; if (issues.some(i => i.severity === "warning")) return "warning";
if (issues.some(i => i.severity === "soft")) return "soft"; 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) { function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) {
const initial = useMemo(() => fixtureToFlow(data), [data]); const model = useModel();
const [nodes, setNodes] = useState<Node<BlockNodeData>[]>(initial.nodes); const apply = useApply();
const [edges, setEdges] = useState<Edge<SysmlEdgeData>[]>(initial.edges);
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const onNodesChange = useCallback( // Initialize React Flow state from the model + fixture positions on first render.
(changes: NodeChange[]) => setNodes(ns => applyNodeChanges(changes, ns) as Node<BlockNodeData>[]), // Lazy `useState`-style init to avoid recomputing on every render.
[] const [initialN] = useState(() => initialNodes(model, data));
); const [initialE] = useState(() => initialEdges(model));
const onEdgesChange = useCallback( const [nodes, setNodes, onNodesChangeRaw] = useNodesState<Node<BlockNodeData>>(initialN);
(changes: EdgeChange[]) => setEdges(es => applyEdgeChanges(changes, es) as Edge<SysmlEdgeData>[]), const [edges, setEdges, onEdgesChangeRaw] = useEdgesState<Edge<SysmlEdgeData>>(initialE);
[]
);
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>[]);
}, []);
// Highlight focusBlockId from sibling components (e.g. narrative chip hover). // Track the model's element ids so we can detect adds/removes between renders.
// Also surface validation severity onto each node so BlockNode can ring it. const elementIdsRef = useRef<{ blocks: Set<string>; assocs: Set<string>; constraints: Set<string> }>(snapshotIds(model));
// ─── Model → React Flow (targeted sync) ──────────────────────────────────
useEffect(() => { useEffect(() => {
setNodes(ns => const prev = elementIdsRef.current;
ns.map(n => { const curr = snapshotIds(model);
const issues = issuesByElement?.get(n.id) ?? [];
const severity = pickWorst(issues); setNodes(currentNodes => {
return { const byId = new Map(currentNodes.map(n => [n.id, n]));
...n, const next: Node<BlockNodeData>[] = [];
selected: n.id === focusBlockId,
data: { ...(n.data as BlockNodeData), issueSeverity: severity }, // Existing model elements first — preserve position & selection from RF state
}; for (const b of model.blocks) {
}) const existing = byId.get(b.id);
); if (existing) {
}, [focusBlockId, issuesByElement]); // 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( const onNodeClick: NodeMouseHandler = useCallback(
(_event, node) => { (_event, node) => onSelect?.(node.id),
onSelect?.(node.id);
},
[onSelect] [onSelect]
); );
const onPaneClick = useCallback(() => onSelect?.(null), [onSelect]);
const onPaneClick = useCallback(() => { const selectedBlock = useMemo(
onSelect?.(null); () => model.blocks.find(b => b.id === focusBlockId) ?? null,
}, [onSelect]); [model.blocks, focusBlockId]
);
const selectedNode = nodes.find(n => n.id === focusBlockId); const selectedConstraint = useMemo(
() => model.constraints.find(c => c.id === focusBlockId) ?? null,
[model.constraints, focusBlockId]
);
// Drag-and-drop from the palette // Drag-and-drop from the palette
const onDragOver = useCallback((event: React.DragEvent) => { const onDragOver = useCallback((event: React.DragEvent) => {
@@ -138,46 +295,94 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
event.preventDefault(); event.preventDefault();
const kindRaw = event.dataTransfer.getData("application/sysml-kind"); const kindRaw = event.dataTransfer.getData("application/sysml-kind");
if (!kindRaw) return; if (!kindRaw) return;
const kind = kindRaw as BlockKind | "system";
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const id = freshNodeId();
const newNode: Node<BlockNodeData> = { if (kindRaw === "constraint") {
id, const cid = tempId("c");
type: "sysmlBlock", const result = apply([
position, addConstraintOp({ id: cid, label: "Constraint", expression: "{ }", appliesTo: [] }, cid),
data: { ]);
label: kind === "system" if (result.applied) {
? "System" const final = result.idMapping[cid] ?? cid;
: kind === "actor" // Position the new node at the drop coordinates
? "Actor" setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
: kind === "constraint" onSelect?.(final);
? "Constraint" }
: "Block", return;
kind, }
properties: kind === "constraint" ? [] : ["new_property"],
...(kind === "constraint" ? { expression: "{ }" } : {}), 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]); const result = apply([addBlockOp(block, bid)]);
onSelect?.(id); 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>) { function patchSelected(patch: Partial<BlockNodeData>) {
if (!focusBlockId) return; if (selectedBlock) {
setNodes(ns => const ops: ModelOp[] = [];
ns.map(n => (n.id === focusBlockId ? { ...n, data: { ...(n.data as BlockNodeData), ...patch } } : n)) 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() { function deleteSelected() {
if (!focusBlockId) return; if (selectedBlock) {
setNodes(ns => ns.filter(n => n.id !== focusBlockId)); apply([removeBlockOp(selectedBlock.id)]);
setEdges(es => es.filter(e => e.source !== focusBlockId && e.target !== focusBlockId)); onSelect?.(null);
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 ( return (
<div ref={wrapperRef} className="diagram-flow" onDragOver={onDragOver} onDrop={onDrop}> <div ref={wrapperRef} className="diagram-flow" onDragOver={onDragOver} onDrop={onDrop}>
<ReactFlow <ReactFlow
@@ -190,6 +395,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
onConnect={onConnect} onConnect={onConnect}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onPaneClick={onPaneClick} onPaneClick={onPaneClick}
deleteKeyCode={["Backspace", "Delete"]}
fitView fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false, maxZoom: 1.2 }} fitViewOptions={{ padding: 0.15, includeHiddenNodes: false, maxZoom: 1.2 }}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
@@ -204,10 +410,10 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
<Palette /> <Palette />
{selectedNode && ( {inspectorData && focusBlockId && (
<NodeInspector <NodeInspector
nodeId={selectedNode.id} nodeId={focusBlockId}
data={selectedNode.data as BlockNodeData} data={inspectorData}
onChange={patchSelected} onChange={patchSelected}
onDelete={deleteSelected} onDelete={deleteSelected}
onClose={() => onSelect?.(null)} onClose={() => onSelect?.(null)}
@@ -216,3 +422,137 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
</div> </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"; "use client";
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from "@xyflow/react"; 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> { export interface SysmlEdgeData extends Record<string, unknown> {
label?: string; label?: string;
@@ -26,7 +26,7 @@ export function SysmlEdge(props: EdgeProps) {
curvature: 0.25, curvature: 0.25,
}); });
const isConstraint = kind === "constraint"; const isConstraint = kind === "constraintApplies";
return ( 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 };
}

View File

@@ -1,11 +1,10 @@
// The dual-canvas workspace shell. // The dual-canvas workspace shell.
// Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar. // M5: state lives in ModelStoreProvider; both canvases consume the canonical
// M4 adds: SysML model derivation + validation, IssuesPanel, optional ?break=... // SysMLModel and emit ModelOps back through useApply().
// query param to demonstrate the validator surfacing rule violations.
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { LeftRail } from "./LeftRail"; 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 { MarkupStyle } from "../text-canvas/Chip";
import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { FixtureData } from "../../lib/fixtures/aristotle";
import { fromFixture } from "../../lib/sysml/fromFixture"; import { fromFixture } from "../../lib/sysml/fromFixture";
import { validate, type ValidationIssue } from "../../lib/sysml/validate";
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks"; import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
interface EditorShellProps { interface EditorShellProps {
data: FixtureData; data: FixtureData;
@@ -29,42 +28,62 @@ interface EditorShellProps {
presence?: SocratesPresence; presence?: SocratesPresence;
} }
export function EditorShell({ export function EditorShell(props: EditorShellProps) {
return (
<Suspense fallback={null}>
<EditorShellInner {...props} />
</Suspense>
);
}
function EditorShellInner({
data, data,
density = "comfortable", density = "comfortable",
markupStyle = "color", markupStyle = "color",
diagramStyle = "softened", diagramStyle = "softened",
presence = "default", presence = "default",
}: EditorShellProps) { }: EditorShellProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions.
const breaks = useMemo<BreakName[]>(() => { const breaks = useMemo<BreakName[]>(() => {
const raw = searchParams?.get("break") ?? ""; const raw = searchParams?.get("break") ?? "";
if (!raw) return []; if (!raw) return [];
return raw return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
.split(",")
.map(s => s.trim())
.filter((s): s is BreakName => s in BREAKS);
}, [searchParams]); }, [searchParams]);
// Derive the SysML model from the fixture, optionally apply breaks, validate. const initialModel = useMemo(() => {
const { issues, issuesByElement } = useMemo(() => { const base = fromFixture(data);
const baseModel = fromFixture(data); return breaks.length > 0 ? applyBreaks(base, breaks) : base;
const broken = breaks.length > 0 ? applyBreaks(baseModel, breaks) : baseModel;
const issues = validate(broken);
const issuesByElement = new Map<string, ValidationIssue[]>();
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 };
}, [data, breaks]); }, [data, breaks]);
return (
<ModelStoreProvider initialModel={initialModel}>
<ShellBody
data={data}
density={density}
markupStyle={markupStyle}
diagramStyle={diagramStyle}
presence={presence}
breaks={breaks}
/>
</ModelStoreProvider>
);
}
function ShellBody({
data,
density,
markupStyle,
diagramStyle,
presence,
breaks,
}: Required<Omit<EditorShellProps, "data">> & { data: FixtureData; breaks: BreakName[] }) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(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 ( return (
<div className={`shell shell-density-${density} shell-presence-${presence}`}> <div className={`shell shell-density-${density} shell-presence-${presence}`}>
<TopBar data={data} /> <TopBar data={data} />
@@ -97,9 +116,7 @@ export function EditorShell({
<section className="canvas canvas-diagram"> <section className="canvas canvas-diagram">
<CanvasHeader <CanvasHeader
title="Model" title="Model"
subtitle={breaks.length > 0 subtitle={subtitle}
? `SysML · breaks active: ${breaks.join(", ")}`
: "SysML · 6 blocks · 6 associations · 1 constraint"}
right={ right={
<div className="canvas-actions"> <div className="canvas-actions">
<span className="canvas-mode-pill">Fit</span> <span className="canvas-mode-pill">Fit</span>
@@ -128,17 +145,3 @@ export function EditorShell({
</div> </div>
); );
} }
/** 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;
}
}

View File

@@ -1,12 +1,17 @@
// Outline / Model / Requirements sections, each independently collapsible. // Outline / Model / Requirements sections, each independently collapsible.
// The whole rail can also collapse to a 36px vertical strip. // 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"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { ValidationIssue, Severity } from "../../lib/sysml/validate"; import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
import { useModel } from "../../lib/sync/ModelStore";
interface LeftRailProps { interface LeftRailProps {
data: FixtureData; data: FixtureData;
@@ -29,10 +34,11 @@ function IssueDot({ severity, title }: { severity: Severity | null; title?: stri
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />; return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
} }
export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) { export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState({ outline: true, model: true, requirements: true }); const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] })); const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
const model = useModel();
if (collapsed) { if (collapsed) {
return ( return (
@@ -47,13 +53,39 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
</button> </button>
<div className="rail-collapsed-stack"> <div className="rail-collapsed-stack">
<span className="rail-collapsed-tag" title="Outline">OUT</span> <span className="rail-collapsed-tag" title="Outline">OUT</span>
<span className="rail-collapsed-tag" title="Model · 6 blocks">MOD</span> <span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
<span className="rail-collapsed-tag" title="Requirements">REQ</span> <span className="rail-collapsed-tag" title="Requirements">REQ</span>
</div> </div>
</nav> </nav>
); );
} }
// Combine blocks + constraints in the Model section, sorted by kind for a
// predictable order: system → block → actor → constraint.
const kindRank: Record<string, number> = { 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 ( return (
<nav className="leftrail"> <nav className="leftrail">
<div className="rail-section"> <div className="rail-section">
@@ -96,7 +128,7 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
</button> </button>
{open.model && ( {open.model && (
<ul className="rail-list rail-blocks"> <ul className="rail-list rail-blocks">
{data.blocks.map(b => { {modelEntries.map(b => {
const sev = maxSeverityForKey(issuesByElement, b.id); const sev = maxSeverityForKey(issuesByElement, b.id);
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n"); const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
return ( return (
@@ -105,19 +137,23 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`} className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)} onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)} onMouseLeave={() => setFocusBlockId(null)}
onClick={() => setFocusBlockId(b.id)}
style={{ cursor: "pointer" }}
> >
<span className="rail-block-glyph"> <span className="rail-block-glyph">
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"} {b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
</span> </span>
<span className="rail-block-label">{b.label}</span> <span className="rail-block-label">{b.label}</span>
<IssueDot severity={sev} title={tooltip} /> <IssueDot severity={sev} title={tooltip} />
<span {b.kind !== "constraint" && (
className="rail-block-count" <span
title={`${b.properties.length} ${b.properties.length === 1 ? "property" : "properties"}`} className="rail-block-count"
> title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
<span className="rail-block-count-glyph">·</span> >
{b.properties.length} <span className="rail-block-count-glyph">·</span>
</span> {b.propertyCount}
</span>
)}
</li> </li>
); );
})} })}
@@ -132,18 +168,18 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
</button> </button>
{open.requirements && ( {open.requirements && (
<ul className="rail-list"> <ul className="rail-list">
{(["req-001", "req-002", "req-003"] as const).map(reqId => { {model.requirements.map(r => {
const tag = reqId.toUpperCase().replace("-", "-"); const key = `req:${r.id}`;
const sev = maxSeverityForKey(issuesByElement, `req:${reqId}`); const sev = maxSeverityForKey(issuesByElement, key);
const tooltip = issuesByElement?.get(`req:${reqId}`)?.map(i => `${i.code}: ${i.message}`).join("\n"); const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
const isUntraced = sev === "warning"; // T1 is the warning we surface here const traced = r.relations.some(rel => rel.kind === "satisfy");
return ( return (
<li key={reqId} className="rail-req"> <li key={r.id} className="rail-req">
<span className="req-tag">{tag.toUpperCase()}</span> <span className="req-tag">{r.tag}</span>
{sev ? ( {sev ? (
<IssueDot severity={sev} title={tooltip} /> <IssueDot severity={sev} title={tooltip} />
) : ( ) : (
<span className={`req-status ${isUntraced ? "req-untraced" : "req-traced"}`} /> <span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
)} )}
</li> </li>
); );

View File

@@ -1,13 +1,20 @@
// React NodeView for the chip TipTap node. // React NodeView for the chip TipTap node.
// Renders identically to the static Chip via the same CSS classes. //
// M5: chips look up their displayed label from the SysMLModel via refId.
// Click a chip → opens an inline rename popover that emits an update-block
// op via useApply(). Renaming here updates every other chip with the same
// refId AND the diagram block label.
"use client"; "use client";
import { NodeViewWrapper } from "@tiptap/react"; import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
import type { ChipKind } from "../../lib/fixtures/aristotle"; import type { ChipKind } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip"; import type { MarkupStyle } from "./Chip";
import { useChipFocus } from "./FocusContext"; import { useChipFocus } from "./FocusContext";
import { useModelStore } from "../../lib/sync/ModelStore";
import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
const KIND_LABEL: Record<ChipKind, string> = { const KIND_LABEL: Record<ChipKind, string> = {
block: "block", block: "block",
@@ -27,30 +34,99 @@ function kindGlyph(kind: ChipKind): string {
export function ChipView({ node, selected, editor }: NodeViewProps) { export function ChipView({ node, selected, editor }: NodeViewProps) {
const kind = (node.attrs.kind as ChipKind) ?? "block"; const kind = (node.attrs.kind as ChipKind) ?? "block";
const label = (node.attrs.label as string) ?? "untitled";
const refId = (node.attrs.refId as string | null) ?? null; const refId = (node.attrs.refId as string | null) ?? null;
// Read the current markup style from the editor's storage; defaults to "color". const fallbackLabel = (node.attrs.label as string) ?? "untitled";
const markupStyle = const markupStyle =
((editor.storage as unknown as Record<string, unknown>).markupStyle as MarkupStyle | undefined) ?? "color"; ((editor.storage as unknown as Record<string, unknown>).markupStyle as MarkupStyle | undefined) ?? "color";
const { focusBlockId, setFocusBlockId } = useChipFocus(); const { focusBlockId, setFocusBlockId } = useChipFocus();
const isFocused = (refId !== null && refId === focusBlockId) || selected; const { model, apply } = useModelStore();
// Resolve the live label from the model, falling back to the node's stored
// label (e.g. for chips created via slash-menu before they're bound).
const liveLabel = useMemo_label(model, kind, refId) ?? fallbackLabel;
const isFocused = (refId !== null && refId === focusBlockId) || selected;
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(liveLabel);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isEditing) {
setDraft(liveLabel);
// Allow the input to mount before focusing.
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, liveLabel]);
function commitRename() {
setIsEditing(false);
const next = draft.trim();
if (!next || next === liveLabel || !refId) return;
if (kind === "block") {
apply([updateBlock(refId, { label: next })]);
} else if (kind === "requirement") {
apply([updateRequirement(refId, { tag: next })]);
} else if (kind === "association") {
apply([updateAssociation(refId, { label: next })]);
}
// property and (potential future) constraint chips: rename in the
// model is more involved (need parent block id resolution); skip for M5.
}
const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}${isEditing ? " chip-editing" : ""}`;
const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}`;
const handlers = refId const handlers = refId
? { ? {
onMouseEnter: () => setFocusBlockId(refId), onMouseEnter: () => setFocusBlockId(refId),
onMouseLeave: () => setFocusBlockId(null), onMouseLeave: () => !isEditing && setFocusBlockId(null),
onDoubleClick: (e: React.MouseEvent) => {
e.preventDefault();
if (refId) setIsEditing(true);
},
onClick: () => setFocusBlockId(refId), onClick: () => setFocusBlockId(refId),
} }
: {}; : {};
// Inline rename input — replaces the label visual, preserves the chip frame
if (isEditing && refId) {
return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false}>
{markupStyle === "bracket" && <span className="chip-bracket">[</span>}
{markupStyle === "bracket" && <span className="chip-kind">{KIND_LABEL[kind]}:</span>}
{markupStyle !== "bracket" && <span className="chip-glyph">{kindGlyph(kind)}</span>}
<input
ref={inputRef}
className="chip-rename"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(liveLabel);
setIsEditing(false);
}
}}
size={Math.max(draft.length, 6)}
/>
{markupStyle === "bracket" && <span className="chip-bracket">]</span>}
</NodeViewWrapper>
);
}
if (markupStyle === "bracket") { if (markupStyle === "bracket") {
return ( return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}> <NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers} title={refId ? "double-click to rename" : undefined}>
<span className="chip-bracket">[</span> <span className="chip-bracket">[</span>
<span className="chip-kind">{KIND_LABEL[kind]}:</span> <span className="chip-kind">{KIND_LABEL[kind]}:</span>
<span className="chip-label">{label}</span> <span className="chip-label">{liveLabel}</span>
<span className="chip-bracket">]</span> <span className="chip-bracket">]</span>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -58,17 +134,54 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
if (markupStyle === "underline") { if (markupStyle === "underline") {
return ( return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}> <NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers} title={refId ? "double-click to rename" : undefined}>
<span className="chip-glyph">{kindGlyph(kind)}</span> <span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span> <span className="chip-label">{liveLabel}</span>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
return ( return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}> <NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers} title={refId ? "double-click to rename" : undefined}>
<span className="chip-glyph">{kindGlyph(kind)}</span> <span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span> <span className="chip-label">{liveLabel}</span>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
/**
* Look up the live label for a chip from the model. Inline-imported and
* collocated to keep the resolution rules near the consumer.
*
* For block / requirement / association / constraint chips we resolve by
* matching `refId` against the corresponding element. Property chips use a
* composite refId ("blockId.propertyId") — handled with a `.` split.
*/
function useMemo_label(model: ReturnType<typeof useModelStore>["model"], kind: ChipKind, refId: string | null): string | null {
if (!refId) return null;
if (kind === "block") {
return model.blocks.find(b => b.id === refId)?.label ?? null;
}
if (kind === "requirement") {
return model.requirements.find(r => r.id === refId || r.tag === refId)?.tag ?? null;
}
if (kind === "association") {
return model.associations.find(a => a.id === refId)?.label ?? null;
}
if (kind === "property") {
const dot = refId.indexOf(".");
if (dot < 0) {
// Bare property name from the fixture; do a flat search.
for (const b of model.blocks) {
const p = b.properties.find(p => p.name === refId || p.id === refId);
if (p) return p.name;
}
return null;
}
const blockId = refId.slice(0, dot);
const propId = refId.slice(dot + 1);
const b = model.blocks.find(x => x.id === blockId);
return b?.properties.find(p => p.id === propId || p.name === propId)?.name ?? null;
}
return null;
}

View File

@@ -0,0 +1,83 @@
// React provider exposing the canonical SysMLModel + applyOps.
// Used by both canvases (and the rail) so they stay in sync.
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { applyOps, type ApplyResult } from "./applyOps";
import { validate, type ValidationIssue } from "../sysml/validate";
import type { SysMLModel } from "../sysml/model";
import type { ModelOp } from "./ops";
export interface ModelStoreValue {
model: SysMLModel;
apply: (ops: ModelOp[]) => ApplyResult;
/** Validation issues, recomputed on each successful apply. */
issues: ValidationIssue[];
issuesByElement: Map<string, ValidationIssue[]>;
}
const ModelStoreContext = createContext<ModelStoreValue | null>(null);
interface ModelStoreProviderProps {
initialModel: SysMLModel;
children: React.ReactNode;
}
export function ModelStoreProvider({ initialModel, children }: ModelStoreProviderProps) {
const [model, setModel] = useState<SysMLModel>(initialModel);
const apply = useCallback((ops: ModelOp[]): ApplyResult => {
let result: ApplyResult = { model, idMapping: {}, errors: [], applied: false };
setModel(current => {
result = applyOps(current, ops);
return result.applied ? result.model : current;
});
return result;
}, [model]);
const { issues, issuesByElement } = useMemo(() => {
const issues = validate(model);
const issuesByElement = new Map<string, ValidationIssue[]>();
for (const i of issues) {
const k = anchorKey(i.anchor);
if (!k) continue;
const prev = issuesByElement.get(k) ?? [];
prev.push(i);
issuesByElement.set(k, prev);
}
return { issues, issuesByElement };
}, [model]);
const value: ModelStoreValue = useMemo(
() => ({ model, apply, issues, issuesByElement }),
[model, apply, issues, issuesByElement]
);
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
}
export function useModelStore(): ModelStoreValue {
const ctx = useContext(ModelStoreContext);
if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
return ctx;
}
export function useModel(): SysMLModel {
return useModelStore().model;
}
export function useApply(): (ops: ModelOp[]) => ApplyResult {
return useModelStore().apply;
}
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;
}
}

View File

@@ -0,0 +1,261 @@
// The single chokepoint for SysMLModel mutations.
//
// Pure: (model, ops) → { model, idMapping, errors }. No DB / network here.
// M5 in-memory: tempIds are used as canonical ids when free, otherwise
// rewritten with a `_n` suffix. The DB layer (later) will replace this with
// server-issued ids.
//
// applyOps is atomic per-batch: if any op fails validation, the entire
// batch is rejected and the original model is returned unchanged. This is
// the contract docs/sync.md §4 specifies.
import type { SysMLModel, Block, Association, Constraint, Requirement } from "../sysml/model";
import type { ModelOp } from "./ops";
export interface ApplyResult {
model: SysMLModel;
/** Map of any tempId → assigned canonical id. */
idMapping: Record<string, string>;
errors: ApplyError[];
/** True iff the batch landed without errors. */
applied: boolean;
}
export interface ApplyError {
opIndex: number;
code: "DUPLICATE_ID" | "MISSING_ELEMENT" | "INVALID_PATCH";
message: string;
}
export function applyOps(model: SysMLModel, ops: ModelOp[]): ApplyResult {
const idMapping: Record<string, string> = {};
const errors: ApplyError[] = [];
// Fail-fast working copy. We mutate this; if errors accumulate we throw it
// away and return the original.
let next = cloneModel(model);
for (let i = 0; i < ops.length; i++) {
const op = ops[i]!;
try {
next = applyOne(next, op, idMapping);
} catch (err) {
errors.push({ opIndex: i, code: classifyError(err), message: (err as Error).message });
}
}
if (errors.length > 0) {
return { model, idMapping: {}, errors, applied: false };
}
return { model: next, idMapping, errors: [], applied: true };
}
// ─── Per-op application ──────────────────────────────────────────────────
function applyOne(model: SysMLModel, op: ModelOp, idMapping: Record<string, string>): SysMLModel {
switch (op.kind) {
case "add-block": {
const id = resolveAddId(op.block.id, op.tempId, idMapping, model.blocks.map(b => b.id));
const block: Block = { ...op.block, id };
return { ...model, blocks: [...model.blocks, block] };
}
case "update-block": {
const realId = idMapping[op.blockId] ?? op.blockId;
assertExists(model.blocks, realId, "block");
return {
...model,
blocks: model.blocks.map(b => (b.id === realId ? { ...b, ...op.patch } : b)),
};
}
case "remove-block": {
const realId = idMapping[op.blockId] ?? op.blockId;
assertExists(model.blocks, realId, "block");
// Cascade: drop incident associations + requirement satisfiers + constraint applies-to
return {
...model,
blocks: model.blocks.filter(b => b.id !== realId),
associations: model.associations.filter(a => a.fromBlockId !== realId && a.toBlockId !== realId),
constraints: model.constraints.map(c => ({ ...c, appliesTo: c.appliesTo.filter(id => id !== realId) })),
requirements: model.requirements.map(r => ({
...r,
relations: r.relations.filter(rel => rel.kind !== "satisfy" || rel.blockId !== realId),
})),
};
}
case "add-property": {
const blockId = idMapping[op.blockId] ?? op.blockId;
const block = model.blocks.find(b => b.id === blockId);
if (!block) throw missing("block", blockId);
const propId = resolveAddId(op.property.id, op.tempId, idMapping, block.properties.map(p => p.id));
return {
...model,
blocks: model.blocks.map(b =>
b.id === blockId ? { ...b, properties: [...b.properties, { ...op.property, id: propId }] } : b
),
};
}
case "update-property": {
const blockId = idMapping[op.blockId] ?? op.blockId;
return {
...model,
blocks: model.blocks.map(b =>
b.id === blockId
? { ...b, properties: b.properties.map(p => (p.id === op.propertyId ? { ...p, ...op.patch } : p)) }
: b
),
};
}
case "remove-property": {
const blockId = idMapping[op.blockId] ?? op.blockId;
return {
...model,
blocks: model.blocks.map(b =>
b.id === blockId ? { ...b, properties: b.properties.filter(p => p.id !== op.propertyId) } : b
),
};
}
case "add-association": {
const id = resolveAddId(op.association.id, op.tempId, idMapping, model.associations.map(a => a.id));
const association: Association = {
...op.association,
id,
fromBlockId: idMapping[op.association.fromBlockId] ?? op.association.fromBlockId,
toBlockId: idMapping[op.association.toBlockId] ?? op.association.toBlockId,
};
return { ...model, associations: [...model.associations, association] };
}
case "update-association": {
const realId = idMapping[op.associationId] ?? op.associationId;
assertExists(model.associations, realId, "association");
return {
...model,
associations: model.associations.map(a => (a.id === realId ? { ...a, ...op.patch } : a)),
};
}
case "remove-association": {
const realId = idMapping[op.associationId] ?? op.associationId;
assertExists(model.associations, realId, "association");
return { ...model, associations: model.associations.filter(a => a.id !== realId) };
}
case "add-constraint": {
const id = resolveAddId(op.constraint.id, op.tempId, idMapping, model.constraints.map(c => c.id));
const constraint: Constraint = {
...op.constraint,
id,
appliesTo: op.constraint.appliesTo.map(b => idMapping[b] ?? b),
};
return { ...model, constraints: [...model.constraints, constraint] };
}
case "update-constraint": {
const realId = idMapping[op.constraintId] ?? op.constraintId;
assertExists(model.constraints, realId, "constraint");
return {
...model,
constraints: model.constraints.map(c => (c.id === realId ? { ...c, ...op.patch } : c)),
};
}
case "remove-constraint": {
const realId = idMapping[op.constraintId] ?? op.constraintId;
assertExists(model.constraints, realId, "constraint");
return { ...model, constraints: model.constraints.filter(c => c.id !== realId) };
}
case "add-requirement": {
const id = resolveAddId(op.requirement.id, op.tempId, idMapping, model.requirements.map(r => r.id));
const requirement: Requirement = { ...op.requirement, id };
return { ...model, requirements: [...model.requirements, requirement] };
}
case "update-requirement": {
const realId = idMapping[op.requirementId] ?? op.requirementId;
assertExists(model.requirements, realId, "requirement");
return {
...model,
requirements: model.requirements.map(r => (r.id === realId ? { ...r, ...op.patch } : r)),
};
}
case "remove-requirement": {
const realId = idMapping[op.requirementId] ?? op.requirementId;
assertExists(model.requirements, realId, "requirement");
return { ...model, requirements: model.requirements.filter(r => r.id !== realId) };
}
case "add-relation": {
const realId = idMapping[op.requirementId] ?? op.requirementId;
assertExists(model.requirements, realId, "requirement");
return {
...model,
requirements: model.requirements.map(r =>
r.id === realId ? { ...r, relations: [...r.relations, op.relation] } : r
),
};
}
case "remove-relation": {
const realId = idMapping[op.requirementId] ?? op.requirementId;
assertExists(model.requirements, realId, "requirement");
return {
...model,
requirements: model.requirements.map(r =>
r.id === realId
? { ...r, relations: r.relations.filter((_, idx) => idx !== op.relationIndex) }
: r
),
};
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────
function cloneModel(model: SysMLModel): SysMLModel {
// Shallow-clone collections; nested objects are immutable per our op contract.
return {
systemOfInterestId: model.systemOfInterestId,
blocks: model.blocks.map(b => ({ ...b, properties: [...b.properties], stereotypes: [...b.stereotypes] })),
associations: model.associations.map(a => ({ ...a })),
constraints: model.constraints.map(c => ({ ...c, appliesTo: [...c.appliesTo] })),
requirements: model.requirements.map(r => ({ ...r, relations: [...r.relations] })),
};
}
/**
* Decide what id to use for a newly-added element.
* - If the requested id is unused, it sticks. (Most common path.)
* - Otherwise, append `_2`, `_3`, … until free, and record the mapping from
* the requested id to the assigned id so subsequent ops in the same batch
* can reference the new element by its tempId.
*/
function resolveAddId(
desiredId: string,
tempId: string | undefined,
idMapping: Record<string, string>,
existing: string[]
): string {
const taken = new Set(existing);
if (!taken.has(desiredId)) {
if (tempId) idMapping[tempId] = desiredId;
return desiredId;
}
let suffix = 2;
while (taken.has(`${desiredId}_${suffix}`)) suffix++;
const final = `${desiredId}_${suffix}`;
if (tempId) idMapping[tempId] = final;
idMapping[desiredId] = final;
return final;
}
function assertExists<T extends { id: string }>(items: T[], id: string, what: string): void {
if (!items.find(i => i.id === id)) throw missing(what, id);
}
function missing(what: string, id: string): Error {
return new Error(`${what} "${id}" not found`);
}
function classifyError(err: unknown): ApplyError["code"] {
const msg = (err as Error).message ?? "";
if (msg.includes("not found")) return "MISSING_ELEMENT";
if (msg.includes("duplicate")) return "DUPLICATE_ID";
return "INVALID_PATCH";
}

235
apps/web/lib/sync/ops.ts Normal file
View File

@@ -0,0 +1,235 @@
// The canonical ModelOp alphabet — every mutation to a SysMLModel is
// expressible as one or more of these. See docs/sync.md §3 for the design.
//
// `tempId` lets clients optimistically create elements with a client-generated
// id; applyOps may rewrite to a canonical id and return the mapping. (M5
// in-memory: tempId IS the canonical id; the rewrite seam is here for the
// later DB / SSE iteration.)
import type {
Block,
BlockKind,
Association,
AssociationKind,
Constraint,
Property,
PropertyType,
Multiplicity,
Requirement,
RequirementRelation,
} from "../sysml/model";
// ─── Block ops ───────────────────────────────────────────────────────────
export interface AddBlockOp {
kind: "add-block";
block: Block;
tempId?: string;
}
export interface UpdateBlockOp {
kind: "update-block";
blockId: string;
patch: Partial<Pick<Block, "label" | "kind" | "stereotypes" | "description">>;
}
export interface RemoveBlockOp {
kind: "remove-block";
blockId: string;
}
// ─── Property ops ────────────────────────────────────────────────────────
export interface AddPropertyOp {
kind: "add-property";
blockId: string;
property: Property;
tempId?: string;
}
export interface UpdatePropertyOp {
kind: "update-property";
blockId: string;
propertyId: string;
patch: Partial<Pick<Property, "name" | "type" | "multiplicity" | "description">>;
}
export interface RemovePropertyOp {
kind: "remove-property";
blockId: string;
propertyId: string;
}
// ─── Association ops ─────────────────────────────────────────────────────
export interface AddAssociationOp {
kind: "add-association";
association: Association;
tempId?: string;
}
export interface UpdateAssociationOp {
kind: "update-association";
associationId: string;
patch: Partial<Pick<Association, "fromBlockId" | "toBlockId" | "label" | "kind" | "multiplicity">>;
}
export interface RemoveAssociationOp {
kind: "remove-association";
associationId: string;
}
// ─── Constraint ops ──────────────────────────────────────────────────────
export interface AddConstraintOp {
kind: "add-constraint";
constraint: Constraint;
tempId?: string;
}
export interface UpdateConstraintOp {
kind: "update-constraint";
constraintId: string;
patch: Partial<Pick<Constraint, "label" | "expression" | "appliesTo">>;
}
export interface RemoveConstraintOp {
kind: "remove-constraint";
constraintId: string;
}
// ─── Requirement ops ─────────────────────────────────────────────────────
export interface AddRequirementOp {
kind: "add-requirement";
requirement: Requirement;
tempId?: string;
}
export interface UpdateRequirementOp {
kind: "update-requirement";
requirementId: string;
patch: Partial<Pick<Requirement, "tag" | "text">>;
}
export interface RemoveRequirementOp {
kind: "remove-requirement";
requirementId: string;
}
export interface AddRelationOp {
kind: "add-relation";
requirementId: string;
relation: RequirementRelation;
}
export interface RemoveRelationOp {
kind: "remove-relation";
requirementId: string;
relationIndex: number;
}
// ─── Union ───────────────────────────────────────────────────────────────
export type ModelOp =
| AddBlockOp
| UpdateBlockOp
| RemoveBlockOp
| AddPropertyOp
| UpdatePropertyOp
| RemovePropertyOp
| AddAssociationOp
| UpdateAssociationOp
| RemoveAssociationOp
| AddConstraintOp
| UpdateConstraintOp
| RemoveConstraintOp
| AddRequirementOp
| UpdateRequirementOp
| RemoveRequirementOp
| AddRelationOp
| RemoveRelationOp;
// ─── Op constructors (call sites stay readable) ─────────────────────────
export function addBlock(block: Block, tempId?: string): AddBlockOp {
return { kind: "add-block", block, tempId };
}
export function updateBlock(blockId: string, patch: UpdateBlockOp["patch"]): UpdateBlockOp {
return { kind: "update-block", blockId, patch };
}
export function removeBlock(blockId: string): RemoveBlockOp {
return { kind: "remove-block", blockId };
}
export function addProperty(blockId: string, property: Property, tempId?: string): AddPropertyOp {
return { kind: "add-property", blockId, property, tempId };
}
export function updateProperty(blockId: string, propertyId: string, patch: UpdatePropertyOp["patch"]): UpdatePropertyOp {
return { kind: "update-property", blockId, propertyId, patch };
}
export function removeProperty(blockId: string, propertyId: string): RemovePropertyOp {
return { kind: "remove-property", blockId, propertyId };
}
export function addAssociation(association: Association, tempId?: string): AddAssociationOp {
return { kind: "add-association", association, tempId };
}
export function updateAssociation(associationId: string, patch: UpdateAssociationOp["patch"]): UpdateAssociationOp {
return { kind: "update-association", associationId, patch };
}
export function removeAssociation(associationId: string): RemoveAssociationOp {
return { kind: "remove-association", associationId };
}
export function addConstraint(constraint: Constraint, tempId?: string): AddConstraintOp {
return { kind: "add-constraint", constraint, tempId };
}
export function updateConstraint(constraintId: string, patch: UpdateConstraintOp["patch"]): UpdateConstraintOp {
return { kind: "update-constraint", constraintId, patch };
}
export function removeConstraint(constraintId: string): RemoveConstraintOp {
return { kind: "remove-constraint", constraintId };
}
export function addRequirement(requirement: Requirement, tempId?: string): AddRequirementOp {
return { kind: "add-requirement", requirement, tempId };
}
export function updateRequirement(requirementId: string, patch: UpdateRequirementOp["patch"]): UpdateRequirementOp {
return { kind: "update-requirement", requirementId, patch };
}
export function removeRequirement(requirementId: string): RemoveRequirementOp {
return { kind: "remove-requirement", requirementId };
}
export function addRelation(requirementId: string, relation: RequirementRelation): AddRelationOp {
return { kind: "add-relation", requirementId, relation };
}
export function removeRelation(requirementId: string, relationIndex: number): RemoveRelationOp {
return { kind: "remove-relation", requirementId, relationIndex };
}
// ─── Helpers ─────────────────────────────────────────────────────────────
let counter = 0;
/** Generate a temp id. Distinct prefix so applyOps can recognize them. */
export function tempId(prefix = "tmp"): string {
counter++;
return `${prefix}_${Date.now().toString(36)}_${counter}`;
}
/** Quick property factory used by clients producing add-property ops. */
export function newProperty(name: string, type: PropertyType = { kind: "string" }, multiplicity: Multiplicity = "0..1"): Property {
return { id: tempId("p"), name, type, multiplicity };
}

View File

@@ -1043,3 +1043,19 @@ button { font-family: inherit; }
.sysml-node-issue-soft { .sysml-node-issue-soft {
border-style: dotted !important; border-style: dotted !important;
} }
/* ─── Chip inline rename (M5) ─── */
.chip-rename {
font-family: inherit;
font-size: 0.86em;
color: inherit;
background: var(--surface);
border: none;
border-bottom: 1px solid var(--accent);
outline: none;
padding: 0 1px;
min-width: 50px;
font-weight: 500;
}
.chip-rename:focus { background: var(--surface-2); }
.chip.chip-editing { box-shadow: 0 0 0 1.5px var(--accent); }