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.
202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
// Outline / Model / Requirements sections, each independently collapsible.
|
||
// The whole rail can also collapse to a 36px vertical strip.
|
||
//
|
||
// M5: Model + Requirements sections read from the canonical SysMLModel via
|
||
// useModelStore() so renames in either canvas reflect here immediately.
|
||
// Outline section is still narrative-derived and uses the fixture (M6 will
|
||
// migrate it to the live ProseMirror outline).
|
||
|
||
"use client";
|
||
|
||
import { useState } from "react";
|
||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
|
||
import { useModel } from "../../lib/sync/ModelStore";
|
||
|
||
interface LeftRailProps {
|
||
data: FixtureData;
|
||
focusBlockId: string | null;
|
||
setFocusBlockId: (id: string | null) => void;
|
||
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
|
||
issuesByElement?: Map<string, ValidationIssue[]>;
|
||
}
|
||
|
||
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
|
||
const items = map?.get(key);
|
||
if (!items || items.length === 0) return null;
|
||
if (items.some(i => i.severity === "error")) return "error";
|
||
if (items.some(i => i.severity === "warning")) return "warning";
|
||
return "soft";
|
||
}
|
||
|
||
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
|
||
if (!severity) return null;
|
||
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
|
||
}
|
||
|
||
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
|
||
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
|
||
const model = useModel();
|
||
|
||
if (collapsed) {
|
||
return (
|
||
<nav className="leftrail leftrail-collapsed">
|
||
<button
|
||
className="rail-collapse-btn"
|
||
onClick={() => setCollapsed(false)}
|
||
title="Expand rail"
|
||
type="button"
|
||
>
|
||
›
|
||
</button>
|
||
<div className="rail-collapsed-stack">
|
||
<span className="rail-collapsed-tag" title="Outline">OUT</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>
|
||
</div>
|
||
</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 (
|
||
<nav className="leftrail">
|
||
<div className="rail-section">
|
||
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
|
||
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}>▸</span>
|
||
<span className="rail-label">Outline</span>
|
||
</button>
|
||
{open.outline && (
|
||
<ul className="rail-list">
|
||
<li className="rail-item rail-item-active">
|
||
<span className="rail-item-text">Problem framing</span>
|
||
</li>
|
||
<li className="rail-item">
|
||
<span className="rail-item-text">Constraints</span>
|
||
<span className="rail-count rail-count-req">3</span>
|
||
</li>
|
||
<li className="rail-item">
|
||
<span className="rail-item-text">Why now</span>
|
||
</li>
|
||
<li className="rail-item rail-item-muted">
|
||
<span className="rail-item-text">Hypotheses</span>
|
||
<span className="rail-count rail-count-asm">3</span>
|
||
</li>
|
||
<li className="rail-item rail-item-muted">
|
||
<span className="rail-item-text">Open questions</span>
|
||
<span className="rail-count rail-count-q">5</span>
|
||
</li>
|
||
<li className="rail-item rail-item-muted">
|
||
<span className="rail-item-text">Risks</span>
|
||
<span className="rail-count rail-count-risk">2</span>
|
||
</li>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rail-section">
|
||
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
|
||
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}>▸</span>
|
||
<span className="rail-label">Model</span>
|
||
</button>
|
||
{open.model && (
|
||
<ul className="rail-list rail-blocks">
|
||
{modelEntries.map(b => {
|
||
const sev = maxSeverityForKey(issuesByElement, b.id);
|
||
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||
return (
|
||
<li
|
||
key={b.id}
|
||
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
|
||
onMouseEnter={() => setFocusBlockId(b.id)}
|
||
onMouseLeave={() => setFocusBlockId(null)}
|
||
onClick={() => setFocusBlockId(b.id)}
|
||
style={{ cursor: "pointer" }}
|
||
>
|
||
<span className="rail-block-glyph">
|
||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
|
||
</span>
|
||
<span className="rail-block-label">{b.label}</span>
|
||
<IssueDot severity={sev} title={tooltip} />
|
||
{b.kind !== "constraint" && (
|
||
<span
|
||
className="rail-block-count"
|
||
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
|
||
>
|
||
<span className="rail-block-count-glyph">·</span>
|
||
{b.propertyCount}
|
||
</span>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rail-section">
|
||
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
|
||
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}>▸</span>
|
||
<span className="rail-label">Requirements</span>
|
||
</button>
|
||
{open.requirements && (
|
||
<ul className="rail-list">
|
||
{model.requirements.map(r => {
|
||
const key = `req:${r.id}`;
|
||
const sev = maxSeverityForKey(issuesByElement, key);
|
||
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||
const traced = r.relations.some(rel => rel.kind === "satisfy");
|
||
return (
|
||
<li key={r.id} className="rail-req">
|
||
<span className="req-tag">{r.tag}</span>
|
||
{sev ? (
|
||
<IssueDot severity={sev} title={tooltip} />
|
||
) : (
|
||
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
className="rail-collapse-btn rail-collapse-btn-bottom"
|
||
onClick={() => setCollapsed(true)}
|
||
title="Collapse rail"
|
||
type="button"
|
||
>
|
||
‹
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|