Files
dtoro 384cbb4ae9 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.
2026-04-29 00:42:18 +02:00

113 lines
3.4 KiB
TypeScript

// 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 };
}