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.
113 lines
3.4 KiB
TypeScript
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 };
|
|
}
|