From dd86aae90d9be072235b2f92dc4fe5c2780f2743 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Tue, 6 Oct 2026 13:01:05 +0200 Subject: [PATCH 1/2] feat(studio): metamodel diagram in the metamodel editor The metamodel editor gets a List / Diagram switch. The Diagram tab draws the metamodel itself: node types as boxes (colour, icon, properties) in a frame per palette category, containment and every relation type as edges that end on the types they connect, with the relation name on each edge. - Placement is Smart Layout followed by the live WebCoLa physics, the same pipeline as the model canvas; edges use the canvas's port allocation and routing. The physics has a time budget because it does not always converge with nested groups. - The legend filters edge kinds; a narrowed filter keeps and lays out only the types the shown edges connect. - Clicking a type dims everything it is not connected to and frames it; clicking a relation shows only its sources and targets. The inspector lists parents, children, relations and properties, and "Edit type" opens the card in the list. - Node type categories move to @radical/common/metamodel/categories.ts and the Elements palette uses them too. Co-Authored-By: Claude Opus 5.5 --- .../tests/studio/metamodel-diagram.spec.ts | 111 +++ apps/studio/package.json | 1 + .../src/components/MetamodelEditor.tsx | 124 +++- .../components/metamodel/MetamodelDiagram.tsx | 648 ++++++++++++++++++ .../metamodel/MetamodelInspector.tsx | 197 ++++++ .../components/metamodel/metamodelGraph.ts | 470 +++++++++++++ apps/studio/src/renderer/src/studio.css | 278 ++++++++ apps/studio/tests/metamodelGraph.test.ts | 180 +++++ docs/IMPROVEMENTS.md | 22 + package-lock.json | 1 + packages/common/src/metamodel/categories.ts | 28 + packages/common/src/metamodel/index.ts | 2 + packages/ui/src/components/RightPanel.tsx | 13 +- 13 files changed, 2057 insertions(+), 18 deletions(-) create mode 100644 apps/e2e/tests/studio/metamodel-diagram.spec.ts create mode 100644 apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx create mode 100644 apps/studio/src/renderer/src/components/metamodel/MetamodelInspector.tsx create mode 100644 apps/studio/src/renderer/src/components/metamodel/metamodelGraph.ts create mode 100644 apps/studio/tests/metamodelGraph.test.ts create mode 100644 packages/common/src/metamodel/categories.ts diff --git a/apps/e2e/tests/studio/metamodel-diagram.spec.ts b/apps/e2e/tests/studio/metamodel-diagram.spec.ts new file mode 100644 index 00000000..1230300a --- /dev/null +++ b/apps/e2e/tests/studio/metamodel-diagram.spec.ts @@ -0,0 +1,111 @@ +import { test, expect, fixture } from '../../support/fixtures' + +// The metamodel editor's Diagram tab, on the bookstore fixture switched to +// the Governance metamodel (16 node types, 14 relation types). +test.beforeEach(async ({ studio }) => { + const doc = JSON.parse(fixture('bookstore')) + doc.metamodel = { id: 'c4-ddd-governance-builtin', name: 'C4 + DDD + Governance', nodeTypes: {}, relationTypes: {} } + await studio.seedDocument(JSON.stringify(doc)) +}) + +test('the Diagram tab draws every node type and its relations', async ({ page, studio }) => { + await studio.open('v-context', 'metamodel') + const editor = page.getByRole('dialog', { name: 'Metamodel editor' }) + await editor.getByRole('tab', { name: 'Diagram' }).click() + + const diagram = editor.locator('.mmd-canvas') + const legend = editor.locator('.mmd-legend') + await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(16) + await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 }) + await expect(diagram.locator('.react-flow__edge').first()).toBeVisible() + // Smart Layout, then the physics settles it: no two type boxes overlap. + const boxes = await diagram.locator('.react-flow__node-mmType').evaluateAll((els) => + els.map((el) => { + const r = el.getBoundingClientRect() + return { id: (el as HTMLElement).dataset.id, x: r.x, y: r.y, w: r.width, h: r.height } + }), + ) + const overlaps = boxes.flatMap((a, i) => + boxes.slice(i + 1).filter((b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h).map((b) => `${a.id}/${b.id}`), + ) + expect(overlaps).toEqual([]) + + // Types sit in a frame per palette category. + await expect(diagram.locator('.mmd-category-label')).toHaveText(['C4', 'Domain', 'Governance', 'Requirements', 'UX', 'Other']) + // Relation names are on the lines without selecting anything. + await expect(diagram.locator('.mmd-edge-label', { hasText: 'Verifies' })).toBeVisible() + // A relation from a type to itself is a chip, not a loop. + await expect(diagram.getByTestId('rf__node-adr').getByText('↻ Supersedes')).toBeVisible() + if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/diagram.png` }) + + // By default every containment edge and every type's properties are shown. + await expect(legend.getByRole('checkbox', { name: 'Show Contains' })).toBeChecked() + await expect(legend.getByRole('checkbox', { name: 'Show properties on boxes' })).toBeChecked() + await expect(diagram.locator('.react-flow__edge[data-testid^="rf__edge-contains:"]')).toHaveCount(35) + await expect(diagram.getByTestId('rf__node-requirement').getByText('ears_type')).toBeVisible() + + // With Contains off, Blueprint (no relation type reaches it) still shows + // its two allowed parents (Domain, Group), so it does not float. + await legend.getByRole('checkbox', { name: 'Show Contains' }).uncheck() + await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 }) + await expect(diagram.locator('.react-flow__edge[data-testid^="rf__edge-contains:"]')).toHaveCount(2) + + // "none" hides every edge; one relation type back on shows only its edges. + await legend.getByRole('button', { name: 'none' }).click() + await expect(diagram.locator('.react-flow__edge')).toHaveCount(0) + await legend.getByRole('checkbox', { name: 'Show Constrains' }).check() + await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 }) + // ADR, Fitness Function and Requirement each constrain all eight C4 + // element types: one edge per pair, each ending on its type — and only + // those 11 types are left on the diagram. + await expect(diagram.locator('.react-flow__edge')).toHaveCount(24) + await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(11) + if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/constrains.png` }) + + await legend.getByRole('checkbox', { name: 'Show properties on boxes' }).uncheck() + await expect(diagram.getByTestId('rf__node-requirement').getByText('ears_type')).toHaveCount(0, { timeout: 20_000 }) +}) + +test('selecting a type shows its rules; Edit opens it in the list', async ({ page, studio }) => { + await studio.open('v-context', 'metamodel') + const editor = page.getByRole('dialog', { name: 'Metamodel editor' }) + await editor.getByRole('tab', { name: 'Diagram' }).click() + + const diagram = editor.locator('.mmd-canvas') + await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 }) + const zoom = async (): Promise => + diagram.locator('.react-flow__viewport').evaluate((el) => Number(/scale\(([\d.]+)\)/.exec((el as HTMLElement).style.transform)?.[1])) + const overview = await zoom() + + // Selecting a type frames it with what it is connected to. + await diagram.getByTestId('rf__node-blueprint').click() + await expect.poll(zoom).toBeGreaterThan(overview * 1.5) + await diagram.locator('.react-flow__pane').click({ position: { x: 5, y: 5 } }) + await expect.poll(zoom).toBeCloseTo(overview, 2) + + // Selecting a relation shows only its sources and targets: ADR, Fitness + // Function and Requirement constrain the eight C4 element types. + await editor.locator('.mmd-legend').getByRole('button', { name: 'Constrains' }).click() + await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(11) + await expect(diagram.locator('.react-flow__edge')).toHaveCount(24) + await expect(diagram.locator('.mmd-category-label')).toHaveText(['C4', 'Domain', 'Governance', 'Requirements']) + if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/relation.png` }) + await page.keyboard.press('Escape') + await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(16) + + await editor.locator('.mmd-canvas').getByTestId('rf__node-requirement').click() + const inspector = editor.locator('.mm-editor-side') + await expect(inspector.getByText('Relations out')).toBeVisible() + await expect(inspector.getByRole('button', { name: 'Derives from' }).first()).toBeVisible() + if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/selected.png` }) + + // Esc clears the selection before it would close the editor. + await page.keyboard.press('Escape') + await expect(inspector.getByText('Validation')).toBeVisible() + await expect(editor).toBeVisible() + + await editor.locator('.mmd-canvas').getByTestId('rf__node-requirement').click() + await inspector.getByRole('button', { name: 'Edit type' }).click() + await expect(editor.getByRole('tab', { name: 'List' })).toHaveAttribute('aria-selected', 'true') + await expect(editor.locator('.mm-card.focused input.mm-input').first()).toHaveValue('Requirement') +}) diff --git a/apps/studio/package.json b/apps/studio/package.json index b884f147..553caad8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -37,6 +37,7 @@ "@radical/common": "*", "@radical/host-bridge": "*", "@radical/hub-catalogue": "*", + "@radical/layout": "*", "@radical/node-files": "*", "@radical/ui": "*", "@types/react": "^18.2.0", diff --git a/apps/studio/src/renderer/src/components/MetamodelEditor.tsx b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx index fe5029eb..650d413f 100644 --- a/apps/studio/src/renderer/src/components/MetamodelEditor.tsx +++ b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react' +import React, { useState, useMemo, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useDiagramStore } from '@radical/ui/store/diagramStore' import { builtInC4Metamodel, validateModel } from '@radical/common/metamodel' @@ -10,6 +10,14 @@ import type { PropertyDef, PropertyType, } from '@radical/common/metamodel' +import { + MetamodelDiagram, + MetamodelLegend, + DEFAULT_DIAGRAM_FILTER, + type DiagramSelection, +} from './metamodel/MetamodelDiagram' +import { MetamodelInspector } from './metamodel/MetamodelInspector' +import { CONTAINS_EDGE, type MetamodelGraphFilter } from './metamodel/metamodelGraph' // ── small icon helpers ───────────────────────────────────────────────────── @@ -107,23 +115,39 @@ function PropertyEditor({ ) } +/** Opens and scrolls to a card when `focusToken` changes — how the diagram's + * "Edit" lands on the right card in the list. */ +function useCardFocus(focusToken: number | undefined, setOpen: (v: boolean) => void) { + const ref = useRef(null) + useEffect(() => { + if (focusToken === undefined) return + setOpen(true) + requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusToken]) + return ref +} + // ── Node-type card ───────────────────────────────────────────────────────── function NodeTypeCard({ def, allTypes, + focusToken, onChange, onDelete, }: { def: NodeTypeDef allTypes: NodeTypeDef[] + focusToken?: number onChange: (patch: Partial) => void onDelete: () => void }): React.ReactElement { const [open, setOpen] = useState(false) + const ref = useCardFocus(focusToken, setOpen) return ( -
+
setOpen((v) => !v)}> @@ -293,15 +317,18 @@ function NodeTypeCard({ function RelationTypeCard({ def, allTypes, + focusToken, onChange, onDelete, }: { def: RelationTypeDef allTypes: NodeTypeDef[] + focusToken?: number onChange: (patch: Partial) => void onDelete: () => void }): React.ReactElement { const [open, setOpen] = useState(false) + const ref = useCardFocus(focusToken, setOpen) const updatePair = (idx: number, patch: Partial) => onChange({ allowedPairs: def.allowedPairs.map((p, i) => (i === idx ? { ...p, ...patch } : p)) }) @@ -313,7 +340,7 @@ function RelationTypeCard({ } return ( -
+
setOpen((v) => !v)}> @@ -464,6 +491,17 @@ function IssuesPanel(): React.ReactElement { // ── Main perspective ─────────────────────────────────────────────────────── +type EditorTab = 'list' | 'diagram' +const LS_TAB = 'radical-metamodel-tab' + +function readTab(): EditorTab { + try { + return localStorage.getItem(LS_TAB) === 'diagram' ? 'diagram' : 'list' + } catch { + return 'list' + } +} + export function MetamodelEditor(): React.ReactElement { const metamodel = useDiagramStore((s) => s.metamodel) const upsertNodeType = useDiagramStore((s) => s.upsertNodeType) @@ -509,9 +547,32 @@ export function MetamodelEditor(): React.ReactElement { const setAppMode = useDiagramStore((s) => s.setAppMode) const close = (): void => setAppMode('designer') - // Esc closes the editor. + const [tab, setTabState] = useState(readTab) + const setTab = (t: EditorTab): void => { + setTabState(t) + try { localStorage.setItem(LS_TAB, t) } catch { /* storage unavailable */ } + } + const [selection, setSelection] = useState(null) + const [diagramFilter, setDiagramFilter] = useState(DEFAULT_DIAGRAM_FILTER) + // Card to open + scroll to in the list; the counter re-triggers the same card. + const [focusCard, setFocusCard] = useState<{ kind: 'type' | 'relation'; id: string; token: number } | null>(null) + const editInList = (s: NonNullable): void => { + if (s.kind === 'relation' && s.id === CONTAINS_EDGE) return + setFocusCard((cur) => ({ kind: s.kind, id: s.id, token: (cur?.token ?? 0) + 1 })) + setTab('list') + } + const focusTokenFor = (kind: 'type' | 'relation', id: string): number | undefined => + focusCard && focusCard.kind === kind && focusCard.id === id ? focusCard.token : undefined + + // Esc clears the diagram selection first, then closes the editor. + const selectionRef = useRef(selection) + selectionRef.current = selection useEffect(() => { - const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() } + const onKey = (e: KeyboardEvent): void => { + if (e.key !== 'Escape') return + if (selectionRef.current) setSelection(null) + else close() + } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -531,7 +592,7 @@ export function MetamodelEditor(): React.ReactElement { }} >
e.stopPropagation()} onClick={(e) => e.stopPropagation()} role="dialog" @@ -547,7 +608,7 @@ export function MetamodelEditor(): React.ReactElement { ✕
-
+
+
+ {(['list', 'diagram'] as const).map((t) => ( + + ))} +
+ {tab === 'diagram' ? ( + editInList({ kind: 'type', id })} + /> + ) : ( + <>

Node types

@@ -585,6 +669,7 @@ export function MetamodelEditor(): React.ReactElement { key={t.id} def={t} allTypes={nodeTypes} + focusToken={focusTokenFor('type', t.id)} onChange={(patch) => upsertNodeType({ ...t, ...patch })} onDelete={() => removeNodeType(t.id)} /> @@ -605,6 +690,7 @@ export function MetamodelEditor(): React.ReactElement { key={t.id} def={t} allTypes={nodeTypes} + focusToken={focusTokenFor('relation', t.id)} onChange={(patch) => upsertRelationType({ ...t, ...patch })} onDelete={() => removeRelationType(t.id)} /> @@ -617,10 +703,32 @@ export function MetamodelEditor(): React.ReactElement { stored in the metamodel and surface in the Issues panel; full canvas styling for custom types lands in a follow-up.
+ + )}
diff --git a/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx b/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx new file mode 100644 index 00000000..61ab8722 --- /dev/null +++ b/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx @@ -0,0 +1,648 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import ReactFlow, { + Background, + BackgroundVariant, + Controls, + EdgeLabelRenderer, + Handle, + Panel, + Position, + ReactFlowProvider, + useReactFlow, + type Edge, + type EdgeProps, + type Node, + type NodeProps, +} from 'reactflow' +import type { Metamodel } from '@radical/common/metamodel' +import { runSmartLayout } from '@radical/ui/layout/smartLayoutRunner' +import { LiveColaLayout } from '@radical/ui/layout/liveColaLayout' +import { + absolutePositions, + buildMetamodelGraph, + layoutFromModel, + smartLayoutModel, + relationColor, + CONTAINS_COLOR, + CATEGORY_PADDING, + CONTAINS_EDGE, + MAX_VISIBLE_PROPERTIES, + type EdgeEnd, + type EdgeRoute, + type MetamodelGraph, + type MetamodelGraphFilter, + type MetamodelGraphCategory, + type MetamodelGraphNode, + type MetamodelLayout, + type Rect, +} from './metamodelGraph' + +/** What the diagram (and the inspector beside it) is focused on. A relation + * selection's id is a relation type id, or `CONTAINS_EDGE` for containment. */ +export type DiagramSelection = + | { kind: 'type'; id: string } + | { kind: 'relation'; id: string } + | null + +// The whole metamodel at first: every containment edge and each type's +// properties. The legend narrows it down. +export const DEFAULT_DIAGRAM_FILTER: MetamodelGraphFilter = { + showContainment: true, + showProperties: true, + hiddenRelations: new Set(), +} + +// ── Nodes ────────────────────────────────────────────────────────────────── + +interface TypeNodeData { + node: MetamodelGraphNode + showProperties: boolean + dim: boolean + selected: boolean +} + +function TypeNode({ data }: NodeProps): React.ReactElement { + const { node, showProperties, dim, selected } = data + const { def } = node + const props = def.properties ?? [] + const shown = props.length > MAX_VISIBLE_PROPERTIES ? props.slice(0, MAX_VISIBLE_PROPERTIES) : props + const hasChips = node.nestsInSelf || node.selfRelations.length > 0 + return ( +
+ + +
+ + + + {def.label} + {def.hubOnly && hub} +
+ {showProperties && ( +
+ {props.length === 0 &&
no properties
} + {shown.map((p) => ( +
+ + {p.key} + {p.required && *} + + {p.type} +
+ ))} + {props.length > shown.length && ( +
+{props.length - shown.length} more
+ )} +
+ )} + {hasChips && ( +
+ {node.nestsInSelf && ( + + ↻ nests + + )} + {node.selfRelations.map((r) => ( + + ↻ {r.label} + + ))} +
+ )} +
+ ) +} + +interface CategoryNodeData { + category: MetamodelGraphCategory + frame: Rect +} + +/** A category's frame: drawn behind its types, transparent to clicks so the + * edges and the pane under it stay reachable. */ +function CategoryNode({ data }: NodeProps): React.ReactElement { + return ( +
+ {data.category.label} +
+ ) +} + +// ── Edge ─────────────────────────────────────────────────────────────────── + +interface TypeEdgeData { + route: EdgeRoute + color: string + contains: boolean + bidirectional: boolean + label: string + dim: boolean + hovered: boolean +} + +const OUTWARD: Record = { + top: { x: 0, y: -1 }, + bottom: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +} + +/** Arrowhead (or diamond) with its tip on the node border, pointing into the node. */ +function headPath(end: EdgeEnd, shape: 'arrow' | 'diamond'): string { + const o = OUTWARD[end.side] + // Unit vector pointing into the node (the direction the head points). + const ux = -o.x + const uy = -o.y + const at = (back: number, side: number): string => `${end.x - ux * back - uy * side} ${end.y - uy * back + ux * side}` + return shape === 'arrow' + ? `M ${end.x} ${end.y} L ${at(9, 4.5)} L ${at(9, -4.5)} Z` + : `M ${end.x} ${end.y} L ${at(7, 4)} L ${at(14, 0)} L ${at(7, -4)} Z` +} + +function TypeEdge({ id, data }: EdgeProps): React.ReactElement | null { + if (!data) return null + const { route, color, contains, bidirectional, label, dim, hovered } = data + return ( + + + + {contains ? ( + + ) : ( + <> + + {bidirectional && } + + )} + {/* Containment is told apart by its dashes and diamond; every relation + carries its name on the line. */} + {!contains && ( + +
+ {label} +
+
+ )} +
+ ) +} + +const nodeTypes = { mmType: TypeNode, mmCategory: CategoryNode } +const edgeTypes = { mmEdge: TypeEdge } + +// ── Focus ────────────────────────────────────────────────────────────────── + +function unionOf(rects: Rect[]): Rect | null { + if (rects.length === 0) return null + const x = Math.min(...rects.map((r) => r.x)) + const y = Math.min(...rects.map((r) => r.y)) + return { + x, + y, + width: Math.max(...rects.map((r) => r.x + r.width)) - x, + height: Math.max(...rects.map((r) => r.y + r.height)) - y, + } +} + +function focusSets( + graph: MetamodelGraph, + selection: DiagramSelection, +): { nodes: Set; edges: Set } | null { + if (!selection) return null + const nodes = new Set() + const edges = new Set() + for (const e of graph.edges) { + const hit = + selection.kind === 'relation' ? e.type === selection.id : e.source === selection.id || e.target === selection.id + if (!hit) continue + edges.add(e.id) + nodes.add(e.source) + nodes.add(e.target) + } + if (selection.kind === 'type') nodes.add(selection.id) + else + for (const n of graph.nodes) + if (n.selfRelations.some((r) => r.id === selection.id) || (selection.id === CONTAINS_EDGE && n.nestsInSelf)) + nodes.add(n.id) + return { nodes, edges } +} + +// ── Legend / filter (rendered in the editor's side column) ───────────────── + +export function MetamodelLegend({ + metamodel, + filter, + selection, + onFilter, + onSelect, +}: { + metamodel: Metamodel + filter: MetamodelGraphFilter + selection: DiagramSelection + onFilter: (f: MetamodelGraphFilter) => void + onSelect: (s: DiagramSelection) => void +}): React.ReactElement { + const relations = Object.values(metamodel.relationTypes) + const drawable = relations.filter((r) => r.allowedPairs.length > 0) + const anyPair = relations.filter((r) => r.allowedPairs.length === 0) + const isSel = (id: string): boolean => selection?.kind === 'relation' && selection.id === id + const toggle = (id: string): void => { + const hiddenRelations = new Set(filter.hiddenRelations) + if (hiddenRelations.has(id)) hiddenRelations.delete(id) + else hiddenRelations.add(id) + onFilter({ ...filter, hiddenRelations }) + } + return ( +
+ +
+ Edges + + +
+
+ onFilter({ ...filter, showContainment: !filter.showContainment })} + /> + + + + + +
+ {drawable.map((r) => { + const color = relationColor(metamodel, r.id) + return ( +
+ toggle(r.id)} + /> + + + + + +
+ ) + })} + {anyPair.length > 0 && ( +
Between any types: {anyPair.map((r) => r.label).join(', ')}
+ )} +
+ Click a type or an edge to focus it. Double-click a type to edit it. +
+
+ ) +} + +// ── Diagram ──────────────────────────────────────────────────────────────── + +// Smart Layout plus the physics take a few seconds; keep the settled result +// so switching tabs or toggling a filter back does not re-run them. +const layoutCache = new Map() +const LAYOUT_CACHE_SIZE = 12 +/** The physics stops once no box moved more than this for PHYSICS_STILL_TICKS ticks… */ +const PHYSICS_STILL_PX = 0.5 +const PHYSICS_STILL_TICKS = 20 +/** …or after this long, whichever comes first. */ +const PHYSICS_BUDGET_MS = 4000 + +function graphKey(graph: MetamodelGraph): string { + return JSON.stringify([ + graph.nodes.map((n) => [n.id, n.category, n.width, n.height]), + graph.edges.map((e) => e.id), + ]) +} + +interface Props { + metamodel: Metamodel + filter: MetamodelGraphFilter + selection: DiagramSelection + onSelect: (s: DiagramSelection) => void + /** Double-click: open the type in the list editor. */ + onEdit: (typeId: string) => void +} + +function DiagramInner({ metamodel, filter, selection, onSelect, onEdit }: Props): React.ReactElement { + const [hoverEdge, setHoverEdge] = useState(null) + const [layout, setLayout] = useState<{ graph: MetamodelGraph; layout: MetamodelLayout } | null>(null) + const [error, setError] = useState(null) + const [phase, setPhase] = useState<'smart' | 'physics' | 'done'>('smart') + const [fitToken, setFitToken] = useState(0) + // Hidden until the first fit, so the picture does not flash at 100 % zoom. + const [fitted, setFitted] = useState(false) + const rf = useReactFlow() + + const graph = useMemo(() => buildMetamodelGraph(metamodel, filter), [metamodel, filter]) + + // Same pipeline as Smart Layout on the model canvas: Smart Layout places + // everything, then the live WebCoLa physics settles the result from those + // positions (no re-arrangement from scratch) — link lengths pull related + // types together, overlaps are pushed apart, groups hold their members. + useEffect(() => { + let cancelled = false + let settled = false + let cola: LiveColaLayout | null = null + let budget: ReturnType | undefined + let frame = 0 + const key = graphKey(graph) + const cached = layoutCache.get(key) + if (cached) { + setLayout({ graph, layout: cached }) + setPhase('done') + setFitToken((t) => t + 1) + return + } + setError(null) + setPhase('smart') + smartLayoutModel(graph, (nodes, relations) => runSmartLayout(nodes, relations)) + .then((model) => { + if (cancelled) return + const publish = (): void => setLayout({ graph, layout: layoutFromModel(graph, model) }) + publish() + setFitToken((t) => t + 1) + setPhase('physics') + + const settle = (): void => { + if (settled || cancelled) return + settled = true + cola?.stop() + clearTimeout(budget) + cancelAnimationFrame(frame) + const l = layoutFromModel(graph, model) + layoutCache.set(key, l) + if (layoutCache.size > LAYOUT_CACHE_SIZE) layoutCache.delete(layoutCache.keys().next().value!) + setLayout({ graph, layout: l }) + setPhase('done') + setFitToken((t) => t + 1) + } + + let last = absolutePositions(graph, model) + let stillTicks = 0 + cola = new LiveColaLayout({ + getModel: () => model, + applyPositions: (positions) => { + for (const [id, p] of Object.entries(positions)) { + const n = model.nodes[id] + if (!n) continue + n.x = p.x + n.y = p.y + if (p.width != null) n.width = p.width + if (p.height != null) n.height = p.height + } + const now = absolutePositions(graph, model) + let moved = 0 + for (const id of Object.keys(now)) + moved = Math.max(moved, Math.abs(now[id].x - last[id].x), Math.abs(now[id].y - last[id].y)) + last = now + stillTicks = moved < PHYSICS_STILL_PX ? stillTicks + 1 : 0 + if (stillTicks >= PHYSICS_STILL_TICKS) return settle() + // One redraw per frame however often cola ticks. + if (!frame) + frame = requestAnimationFrame(() => { + frame = 0 + if (!cancelled && !settled) publish() + }) + }, + }) + // skipBulk: start from Smart Layout's positions, as the canvas does + // after Smart Layout, instead of cola's own initial arrangement. + cola.start(true) + // WebCoLa does not always converge with nested groups (see + // known-issues.spec.ts), so the physics gets a time budget. + budget = setTimeout(settle, PHYSICS_BUDGET_MS) + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + }) + return () => { + cancelled = true + cola?.stop() + clearTimeout(budget) + cancelAnimationFrame(frame) + } + }, [graph]) + + // Fit after Smart Layout and again once the physics has settled. The + // bounds come from the layout itself (the category frames wrap every box), + // not from ReactFlow's measurements, which lag behind while the physics + // replaces the nodes every frame. + useEffect(() => { + if (!fitToken || !layout) return + const bounds = unionOf(Object.values(layout.layout.frames)) + if (!bounds) return + const id = requestAnimationFrame(() => { + rf.fitBounds(bounds, { padding: 0.06, duration: fitted ? 300 : 0 }) + setFitted(true) + }) + return () => cancelAnimationFrame(id) + // Only a new fitToken re-fits; the layout read here is the one it came with. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitToken, rf]) + + const focus = useMemo(() => (layout ? focusSets(layout.graph, selection) : null), [layout, selection]) + + // Selecting something frames it with everything it is connected to; + // clearing the selection frames the whole diagram again. + const selectionKey = selection ? `${selection.kind}:${selection.id}` : '' + const lastSelectionKey = useRef(selectionKey) + useEffect(() => { + if (lastSelectionKey.current === selectionKey) return + lastSelectionKey.current = selectionKey + if (!layout || !fitted) return + const { positions, frames } = layout.layout + const boxes = focus + ? layout.graph.nodes + .filter((n) => focus.nodes.has(n.id) && positions[n.id]) + .map((n) => ({ ...positions[n.id], width: n.width, height: n.height })) + : Object.values(frames) + const bounds = unionOf(boxes.length ? boxes : Object.values(frames)) + if (bounds) rf.fitBounds(bounds, { padding: focus ? 0.15 : 0.06, duration: 400 }) + // Only a change of selection re-frames; physics ticks must not move the camera. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionKey]) + + // A selected relation shows only its sources and targets: every other type + // and edge is hidden, and each category frame wraps just its remaining + // types. A selected type keeps the rest visible but dimmed. + const onlyFocus = selection?.kind === 'relation' && !!focus + + const nodes = useMemo((): Node[] => { + if (!layout) return [] + const { positions, frames } = layout.layout + const byId = new Map(layout.graph.nodes.map((n) => [n.id, n])) + const frameOf = (category: MetamodelGraphCategory): Rect | null => { + if (!onlyFocus) return frames[category.id] + const inner = unionOf( + category.members + .filter((id) => focus!.nodes.has(id)) + .map((id) => ({ ...positions[id], width: byId.get(id)!.width, height: byId.get(id)!.height })), + ) + return inner && { + x: inner.x - CATEGORY_PADDING.side, + y: inner.y - CATEGORY_PADDING.top, + width: inner.width + 2 * CATEGORY_PADDING.side, + height: inner.height + CATEGORY_PADDING.top + CATEGORY_PADDING.bottom, + } + } + return [ + // Frames first and lowest so the types and edges paint over them. + ...layout.graph.categories.map((category) => { + const frame = frameOf(category) + return { + id: category.id, + type: 'mmCategory', + position: frame ? { x: frame.x, y: frame.y } : { x: 0, y: 0 }, + hidden: !frame, + draggable: false, + connectable: false, + selectable: false, + zIndex: -1, + data: { category, frame: frame ?? frames[category.id] }, + } + }), + ...layout.graph.nodes.map((n) => ({ + id: n.id, + type: 'mmType', + position: positions[n.id] ?? { x: 0, y: 0 }, + hidden: onlyFocus && !focus!.nodes.has(n.id), + draggable: false, + connectable: false, + data: { + node: n, + showProperties: filter.showProperties, + dim: !!focus && !focus.nodes.has(n.id), + selected: selection?.kind === 'type' && selection.id === n.id, + }, + })), + ] + }, [layout, focus, selection, onlyFocus, filter.showProperties]) + + const edges = useMemo((): Edge[] => { + if (!layout) return [] + return layout.graph.edges + .filter((e) => layout.layout.routes[e.id]) + .map((e) => { + const inFocus = !!focus?.edges.has(e.id) + return { + id: e.id, + source: e.source, + target: e.target, + type: 'mmEdge', + hidden: onlyFocus && !inFocus, + // Focused edges paint over the dimmed rest. + zIndex: inFocus ? 1 : 0, + data: { + route: layout.layout.routes[e.id], + color: e.color, + contains: e.type === CONTAINS_EDGE, + bidirectional: e.bidirectional, + label: e.label, + dim: !!focus && !inFocus, + hovered: hoverEdge === e.id, + }, + } + }) + }, [layout, focus, onlyFocus, hoverEdge]) + + // The layout on screen belongs to an older graph while a new one runs. + const status = error + ? `Layout failed: ${error}` + : !layout || layout.graph !== graph || phase === 'smart' + ? 'Smart Layout…' + : phase === 'physics' + ? 'Settling…' + : null + + return ( +
+ { + if (!metamodel.nodeTypes[n.id] || (selection?.kind === 'type' && selection.id === n.id)) return onSelect(null) + onSelect({ kind: 'type', id: n.id }) + }} + onNodeDoubleClick={(_, n) => { + if (metamodel.nodeTypes[n.id]) onEdit(n.id) + }} + onEdgeClick={(_, e) => { + const type = layout?.graph.edges.find((x) => x.id === e.id)?.type + if (type) onSelect({ kind: 'relation', id: type }) + }} + onEdgeMouseEnter={(_, e) => setHoverEdge(e.id)} + onEdgeMouseLeave={() => setHoverEdge(null)} + onPaneClick={() => onSelect(null)} + > + + + {status && ( + +
{status}
+
+ )} +
+
+ ) +} + +/** Read-only picture of the metamodel: node types as boxes, containment and + * relation types as edges, placed by Smart Layout. Has its own ReactFlow + * store so it never touches the main canvas's. */ +export function MetamodelDiagram(props: Props): React.ReactElement { + return ( + + + + ) +} diff --git a/apps/studio/src/renderer/src/components/metamodel/MetamodelInspector.tsx b/apps/studio/src/renderer/src/components/metamodel/MetamodelInspector.tsx new file mode 100644 index 00000000..a708e2d9 --- /dev/null +++ b/apps/studio/src/renderer/src/components/metamodel/MetamodelInspector.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import { isParentAllowed, type Metamodel, type PropertyDef } from '@radical/common/metamodel' +import { relationColor, typeNeighbourhood, CONTAINS_COLOR, CONTAINS_EDGE } from './metamodelGraph' +import type { DiagramSelection } from './MetamodelDiagram' + +interface Props { + metamodel: Metamodel + selection: NonNullable + onSelect: (s: DiagramSelection) => void + /** Open the selected type / relation type in the list editor. */ + onEdit: (s: NonNullable) => void +} + +function TypeChip({ metamodel, id, onSelect }: { metamodel: Metamodel; id: string; onSelect: Props['onSelect'] }): React.ReactElement { + const def = metamodel.nodeTypes[id] + return ( + + ) +} + +function Properties({ properties }: { properties: PropertyDef[] }): React.ReactElement { + if (properties.length === 0) return
No custom properties.
+ return ( +
+ {properties.map((p) => ( +
+
+ {p.key} + {p.required && *} + {p.type} +
+ {p.label !== p.key &&
{p.label}
} + {p.options && p.options.length > 0 &&
{p.options.join(' · ')}
} +
+ ))} +
+ ) +} + +function RelationGroups({ + metamodel, + groups, + onSelect, +}: { + metamodel: Metamodel + groups: Map + onSelect: Props['onSelect'] +}): React.ReactElement { + if (groups.size === 0) return
None.
+ return ( + <> + {[...groups].map(([relId, typeIds]) => ( +
+ +
+ {typeIds.map((t) => )} +
+
+ ))} + + ) +} + +function TypeInspector({ metamodel, selection, onSelect, onEdit }: Props): React.ReactElement { + const def = metamodel.nodeTypes[selection.id] + if (!def) return
Type “{selection.id}” no longer exists.
+ const nb = typeNeighbourhood(metamodel, def.id) + const atRoot = isParentAllowed(metamodel, def.id, undefined) + const card = def.cardinality + const facts = [ + def.builtin ? 'built-in' : 'custom', + def.hubOnly ? 'Hub only' : null, + def.tableTab ? 'own Table View tab' : null, + card?.min != null || card?.max != null ? `${card?.min ?? 0}…${card?.max ?? '∞'} per document` : null, + ].filter(Boolean) + return ( + <> +
+ + + +
+
{def.label}
+
{def.id}
+
+
+
{facts.join(' · ')}
+ + +
Can be inside
+
+ {atRoot && canvas root} + {nb.parents.map((p) => )} + {!atRoot && nb.parents.length === 0 && Nowhere — the type cannot be placed.} +
+ +
Can contain
+
+ {nb.children.length === 0 && Nothing.} + {nb.children.map((c) => )} +
+ +
Relations out
+ + +
Relations in
+ + +
Properties
+ + + ) +} + +function RelationInspector({ metamodel, selection, onSelect, onEdit }: Props): React.ReactElement { + if (selection.id === CONTAINS_EDGE) { + const pairs = Object.values(metamodel.nodeTypes) + .map((t) => [t.id, (t.allowedParents ?? []).filter((p) => metamodel.nodeTypes[p])] as const) + .filter(([, parents]) => parents.length > 0) + return ( + <> +
+ +
Contains
+
+
+ Which types may be drawn inside which. The diamond marks the parent. Edit it per type under + “Allowed parents”. +
+
Child ← allowed parents
+ {pairs.map(([child, parents]) => ( +
+ +
+ {parents.map((p) => )} +
+
+ ))} + + ) + } + + const def = metamodel.relationTypes[selection.id] + if (!def) return
Relation type “{selection.id}” no longer exists.
+ const bySource = new Map() + for (const { from, to } of def.allowedPairs) { + const list = bySource.get(from) ?? [] + if (!list.includes(to)) list.push(to) + bySource.set(from, list) + } + return ( + <> +
+ +
+
{def.label}
+
{def.id}
+
+
+
+ {def.builtin ? 'built-in' : 'custom'} ·{' '} + {def.allowedPairs.length === 0 ? 'allowed between any types' : `${def.allowedPairs.length} allowed pair(s)`} +
+ + + {bySource.size > 0 &&
From → to
} + {[...bySource].map(([from, tos]) => ( +
+ +
+ → + {tos.map((t) => )} +
+
+ ))} + +
Properties
+ + + ) +} + +export function MetamodelInspector(props: Props): React.ReactElement { + const { kind } = props.selection + return ( +
+ + {kind === 'type' ? : } +
+ ) +} diff --git a/apps/studio/src/renderer/src/components/metamodel/metamodelGraph.ts b/apps/studio/src/renderer/src/components/metamodel/metamodelGraph.ts new file mode 100644 index 00000000..025aca3c --- /dev/null +++ b/apps/studio/src/renderer/src/components/metamodel/metamodelGraph.ts @@ -0,0 +1,470 @@ +// ─── Metamodel → graph ───────────────────────────────────────────────────── +// +// Turns a Metamodel into something drawable: one box per node type, grouped +// in a frame per category (C4, Governance, Requirements, … — the Elements +// palette's sections), one "contains" edge per allowed parent and one edge +// per relation type between two node types. Kept free of React so the shape +// rules and the layout can be tested headless. +// +// Every edge runs between the two node types it connects. Readability rules: +// • A pair allowed in both directions (A→B and B→A) of one relation type is +// a single two-headed edge, not two parallel ones. +// • A pair from a type to itself (ADR supersedes ADR, Group inside Group) +// is not a loop but a chip on the type's box. +// • When the filter hides some edges, only the types those edges connect +// are drawn (and laid out) — the picture is about the chosen relations. +// • With only containment hidden, a type no relation type of the metamodel +// reaches still shows where it may be placed (Blueprint inside Domain / +// Group), so it does not float unconnected. +// • Pairs naming a type that no longer exists are skipped — the Issues +// panel reports the dangling metamodel, the diagram just stays drawable. + +import type { C4Node, C4Relation } from '@radical/common/c4' +import { + NODE_TYPE_CATEGORIES, + nodeTypeCategory, + type Metamodel, + type NodeTypeDef, + type RelationTypeDef, +} from '@radical/common/metamodel' +import { runSmartLayoutCore, type SmartLayoutResult } from '@radical/layout/smartLayout' +import { allocatePorts } from '@radical/layout/portAllocator' +import { computeRoutedEdge, type RoutingObstacle } from '@radical/layout/edgeRouting' +import type { Position as Side } from '@radical/layout/side' + +export const CONTAINS_EDGE = 'contains' +/** Category frames are layout nodes too; the prefix keeps them apart from type ids. */ +export const CATEGORY_ID_PREFIX = 'category:' + +export interface MetamodelGraphNode { + id: string + def: NodeTypeDef + /** Category frame id (`CATEGORY_ID_PREFIX` + category id). */ + category: string + /** The type may be nested inside another node of the same type. */ + nestsInSelf: boolean + /** Relation types allowed from this type to itself. */ + selfRelations: { id: string; label: string; color: string }[] + width: number + height: number +} + +export interface MetamodelGraphCategory { + id: string + label: string + /** Node type ids, in metamodel order. */ + members: string[] +} + +export interface MetamodelGraphEdge { + id: string + /** `'contains'` for containment, otherwise the relation type id. */ + type: string + /** For containment: the parent. For relations: the source type. */ + source: string + target: string + /** Relation allowed both ways between source and target. */ + bidirectional: boolean + label: string + color: string +} + +export interface MetamodelGraph { + nodes: MetamodelGraphNode[] + /** Non-empty categories, in palette order (custom types last). */ + categories: MetamodelGraphCategory[] + edges: MetamodelGraphEdge[] + /** Relation types with an empty `allowedPairs` — allowed between any two + * types, so they have no edges to draw. */ + anyPairRelations: RelationTypeDef[] +} + +export interface MetamodelGraphFilter { + showContainment: boolean + /** List each type's properties on its box (taller boxes, longer layout). */ + showProperties: boolean + /** Relation type ids whose edges are left out. */ + hiddenRelations: ReadonlySet +} + +// ── Box geometry (shared with the node renderer so layout sizes match) ───── + +export const NODE_WIDTH = 220 +export const NODE_HEADER_HEIGHT = 40 +export const NODE_ROW_HEIGHT = 18 +export const NODE_CHIPS_HEIGHT = 26 +export const NODE_PADDING_Y = 8 +/** Properties shown on the box before collapsing the rest into "+N more". */ +export const MAX_VISIBLE_PROPERTIES = 6 +/** Room around a category's types inside its frame (top holds the title). */ +export const CATEGORY_PADDING = { top: 36, side: 20, bottom: 20 } + +export function visiblePropertyRows(def: NodeTypeDef): number { + const n = def.properties?.length ?? 0 + if (n === 0) return 1 // "no properties" placeholder row + return n > MAX_VISIBLE_PROPERTIES ? MAX_VISIBLE_PROPERTIES + 1 : n +} + +function nodeHeight(def: NodeTypeDef, hasChips: boolean, showProperties: boolean): number { + return ( + NODE_HEADER_HEIGHT + + (showProperties ? NODE_PADDING_Y * 2 + visiblePropertyRows(def) * NODE_ROW_HEIGHT : 0) + + (hasChips ? NODE_CHIPS_HEIGHT : 0) + ) +} + +// ── Colours ──────────────────────────────────────────────────────────────── + +export const CONTAINS_COLOR = '#94a3b8' + +const RELATION_PALETTE = [ + '#3b82f6', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', + '#84cc16', '#f97316', '#14b8a6', '#a855f7', '#eab308', '#6366f1', '#22c55e', +] + +/** A relation type's own `color`, else a stable palette slot by its position + * in the metamodel. */ +export function relationColor(metamodel: Metamodel, relationId: string): string { + const def = metamodel.relationTypes[relationId] + if (def?.color) return def.color + const idx = Object.keys(metamodel.relationTypes).indexOf(relationId) + return RELATION_PALETTE[(idx < 0 ? 0 : idx) % RELATION_PALETTE.length] +} + +// ── Graph ────────────────────────────────────────────────────────────────── + +export function buildMetamodelGraph( + metamodel: Metamodel, + filter: MetamodelGraphFilter = { showContainment: true, showProperties: true, hiddenRelations: new Set() }, +): MetamodelGraph { + const types = metamodel.nodeTypes + const edges: MetamodelGraphEdge[] = [] + const selfRelations = new Map() + const anyPairRelations: RelationTypeDef[] = [] + + // Containment: parent → child. + const containmentOf = (child: NodeTypeDef): MetamodelGraphEdge[] => + [...new Set(child.allowedParents ?? [])] + .filter((parent) => parent !== child.id && types[parent]) + .map((parent) => ({ + id: `${CONTAINS_EDGE}:${parent}>${child.id}`, + type: CONTAINS_EDGE, + source: parent, + target: child.id, + bidirectional: false, + label: 'contains', + color: CONTAINS_COLOR, + })) + if (filter.showContainment) for (const child of Object.values(types)) edges.push(...containmentOf(child)) + + for (const rel of Object.values(metamodel.relationTypes)) { + if (rel.allowedPairs.length === 0) { + anyPairRelations.push(rel) + continue + } + if (filter.hiddenRelations.has(rel.id)) continue + const color = relationColor(metamodel, rel.id) + const byKey = new Map() + for (const { from, to } of rel.allowedPairs) { + if (!types[from] || !types[to]) continue + if (from === to) { + const list = selfRelations.get(from) ?? [] + if (!list.some((r) => r.id === rel.id)) list.push({ id: rel.id, label: rel.label, color }) + selfRelations.set(from, list) + continue + } + const key = from < to ? `${from}|${to}` : `${to}|${from}` + const existing = byKey.get(key) + if (existing) { + if (existing.source !== from) existing.bidirectional = true + continue + } + const edge: MetamodelGraphEdge = { + id: `${rel.id}:${from}>${to}`, + type: rel.id, + source: from, + target: to, + bidirectional: false, + label: rel.label, + color, + } + byKey.set(key, edge) + edges.push(edge) + } + } + + const drawable = Object.values(metamodel.relationTypes).filter((r) => r.allowedPairs.length > 0) + const allRelationsShown = drawable.every((r) => !filter.hiddenRelations.has(r.id)) + if (!filter.showContainment && allRelationsShown && drawable.length > 0) { + const related = new Set( + Object.values(metamodel.relationTypes).flatMap((r) => r.allowedPairs.flatMap((p) => [p.from, p.to])), + ) + for (const def of Object.values(types)) if (!related.has(def.id)) edges.push(...containmentOf(def)) + } + + // A narrowed filter keeps only the types its edges (or self-relation chips) + // connect. With nothing shown at all every type stays, so the canvas is + // never empty. + const narrowed = !filter.showContainment || !allRelationsShown + const shown = new Set(edges.flatMap((e) => [e.source, e.target])) + for (const id of selfRelations.keys()) shown.add(id) + if (filter.showContainment) + for (const def of Object.values(types)) if (def.allowedParents?.includes(def.id)) shown.add(def.id) + const keep = (id: string): boolean => !narrowed || shown.size === 0 || shown.has(id) + + const nodes = Object.values(types).filter((def) => keep(def.id)).map((def): MetamodelGraphNode => { + // The "nests" chip is containment too, so it follows the Contains filter. + const nestsInSelf = filter.showContainment && (def.allowedParents?.includes(def.id) ?? false) + const selfRels = selfRelations.get(def.id) ?? [] + return { + id: def.id, + def, + category: CATEGORY_ID_PREFIX + nodeTypeCategory(def.id).id, + nestsInSelf, + selfRelations: selfRels, + width: NODE_WIDTH, + height: nodeHeight(def, nestsInSelf || selfRels.length > 0, filter.showProperties), + } + }) + + const rank = (catId: string): number => { + const i = NODE_TYPE_CATEGORIES.findIndex((c) => CATEGORY_ID_PREFIX + c.id === catId) + return i < 0 ? NODE_TYPE_CATEGORIES.length : i + } + const byCategory = new Map() + for (const n of nodes) { + const cat = byCategory.get(n.category) ?? { id: n.category, label: nodeTypeCategory(n.id).label, members: [] } + cat.members.push(n.id) + byCategory.set(n.category, cat) + } + const categories = [...byCategory.values()].sort((a, b) => rank(a.id) - rank(b.id)) + + return { nodes, categories, edges, anyPairRelations } +} + +// ── Layout ───────────────────────────────────────────────────────────────── +// +// Placement is Smart Layout's, the same ensemble the model canvas uses: each +// category is a group node, each type a leaf inside it, each edge a relation. +// Edges are then routed like the canvas routes them (port allocation per side +// + obstacle-avoiding routing), so the two pictures read the same way. + +export interface Point { + x: number + y: number +} + +export interface Rect extends Point { + width: number + height: number +} + +export interface EdgeEnd extends Point { + side: Side +} + +export interface EdgeRoute { + path: string + source: EdgeEnd + target: EdgeEnd + label: Point +} + +export interface MetamodelLayout { + /** Absolute top-left corner per node type id. */ + positions: Record + /** Absolute frame per category id. */ + frames: Record + routes: Record +} + +/** Runs Smart Layout; Studio passes its Web Worker runner, tests the core. */ +export type SmartLayoutRun = ( + nodes: Record, + relations: Record, +) => Promise + +export function smartLayoutInput(graph: MetamodelGraph): { + nodes: Record + relations: Record +} { + const nodes: Record = {} + for (const c of graph.categories) + nodes[c.id] = { id: c.id, type: 'group', label: c.label, collapsed: false, x: 0, y: 0, width: NODE_WIDTH, height: NODE_HEADER_HEIGHT } + // 'component' is a leaf type: Smart Layout keeps the given size instead of + // treating the box as a (collapsible) container. + for (const n of graph.nodes) + nodes[n.id] = { + id: n.id, + type: 'component', + label: n.def.label, + parentId: n.category, + collapsed: false, + x: 0, + y: 0, + width: n.width, + height: n.height, + } + const relations: Record = {} + for (const e of graph.edges) relations[e.id] = { id: e.id, sourceId: e.source, targetId: e.target, relationType: e.type } + return { nodes, relations } +} + +/** The layout model: a group node per category (absolute position) with + * the type nodes inside it (positions relative to their group) — the shape + * both Smart Layout and the live physics read and write. */ +export interface MetamodelLayoutModel { + nodes: Record + relations: Record +} + +/** Fallback when Smart Layout returns nothing: categories side by side, types stacked. */ +function stackModel(graph: MetamodelGraph, model: MetamodelLayoutModel): void { + graph.categories.forEach((c, i) => { + Object.assign(model.nodes[c.id], { x: i * (NODE_WIDTH + 2 * CATEGORY_PADDING.side + 60), y: 0 }) + let y = CATEGORY_PADDING.top + for (const id of c.members) { + Object.assign(model.nodes[id], { x: CATEGORY_PADDING.side, y }) + y += model.nodes[id].height + 24 + } + }) +} + +/** Absolute top-left of every type box in the model. */ +export function absolutePositions(graph: MetamodelGraph, model: MetamodelLayoutModel): Record { + const out: Record = {} + for (const n of graph.nodes) { + const node = model.nodes[n.id] + const group = model.nodes[n.category] + out[n.id] = { x: (group?.x ?? 0) + node.x, y: (group?.y ?? 0) + node.y } + } + return out +} + +/** Each category's frame: its types' bounding box plus CATEGORY_PADDING. */ +function categoryFrames(graph: MetamodelGraph, positions: Record): Record { + const byId = new Map(graph.nodes.map((n) => [n.id, n])) + const frames: Record = {} + for (const c of graph.categories) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const id of c.members) { + const p = positions[id] + const n = byId.get(id)! + minX = Math.min(minX, p.x) + minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x + n.width) + maxY = Math.max(maxY, p.y + n.height) + } + frames[c.id] = { + x: minX - CATEGORY_PADDING.side, + y: minY - CATEGORY_PADDING.top, + width: maxX - minX + 2 * CATEGORY_PADDING.side, + height: maxY - minY + CATEGORY_PADDING.top + CATEGORY_PADDING.bottom, + } + } + return frames +} + +export function routeMetamodelEdges(graph: MetamodelGraph, positions: Record): Record { + const views = new Map( + graph.nodes.map((n) => [n.id, { id: n.id, positionAbsolute: positions[n.id] ?? { x: 0, y: 0 }, width: n.width, height: n.height }]), + ) + const ports = allocatePorts(views, graph.edges.map((e) => ({ id: e.id, source: e.source, target: e.target }))) + const routes: Record = {} + for (const e of graph.edges) { + const a = ports.get(e.id) + if (!a) continue + // Only type boxes are obstacles; edges cross category frames freely. + const obstacles: RoutingObstacle[] = [] + for (const v of views.values()) { + if (v.id === e.source || v.id === e.target) continue + obstacles.push({ x: v.positionAbsolute.x, y: v.positionAbsolute.y, w: v.width, h: v.height }) + } + const r = computeRoutedEdge( + a.sourcePoint.x, a.sourcePoint.y, a.sourceSide, + a.targetPoint.x, a.targetPoint.y, a.targetSide, + obstacles, + ) + routes[e.id] = { + path: r.path, + source: { ...a.sourcePoint, side: a.sourceSide }, + target: { ...a.targetPoint, side: a.targetSide }, + label: { x: r.labelX, y: r.labelY }, + } + } + return routes +} + +/** Runs Smart Layout and returns its result as a layout model. */ +export async function smartLayoutModel( + graph: MetamodelGraph, + run: SmartLayoutRun = runSmartLayoutCore, +): Promise { + const model = smartLayoutInput(graph) + const placed = (await run(model.nodes, model.relations)).winner.positions + if (Object.keys(model.nodes).some((id) => !placed[id])) { + stackModel(graph, model) + return model + } + for (const [id, node] of Object.entries(model.nodes)) { + const p = placed[id] + node.x = p.x + node.y = p.y + // Group sizes come from Smart Layout; type boxes keep their own. + if (node.parentId === undefined) { + if (p.width) node.width = p.width + if (p.height) node.height = p.height + } + } + return model +} + +/** What the diagram draws for a layout model. Frames are drawn tight around + * their types rather than at the group size (shrinking cannot make two + * frames overlap). */ +export function layoutFromModel(graph: MetamodelGraph, model: MetamodelLayoutModel): MetamodelLayout { + const positions = absolutePositions(graph, model) + return { positions, frames: categoryFrames(graph, positions), routes: routeMetamodelEdges(graph, positions) } +} + +/** Smart Layout's placement, without the live physics that follows it in Studio. */ +export async function layoutMetamodelGraph( + graph: MetamodelGraph, + run: SmartLayoutRun = runSmartLayoutCore, +): Promise { + return layoutFromModel(graph, await smartLayoutModel(graph, run)) +} + +// ── Adjacency (inspector + focus highlighting) ───────────────────────────── + +export interface TypeNeighbourhood { + parents: string[] + children: string[] + /** relation type id → target type ids */ + outgoing: Map + /** relation type id → source type ids */ + incoming: Map +} + +export function typeNeighbourhood(metamodel: Metamodel, typeId: string): TypeNeighbourhood { + const def = metamodel.nodeTypes[typeId] + const parents = (def?.allowedParents ?? []).filter((p) => metamodel.nodeTypes[p]) + const children = Object.values(metamodel.nodeTypes) + .filter((t) => t.allowedParents?.includes(typeId)) + .map((t) => t.id) + const outgoing = new Map() + const incoming = new Map() + const add = (m: Map, k: string, v: string) => { + const list = m.get(k) ?? [] + if (!list.includes(v)) list.push(v) + m.set(k, list) + } + for (const rel of Object.values(metamodel.relationTypes)) { + for (const { from, to } of rel.allowedPairs) { + if (!metamodel.nodeTypes[from] || !metamodel.nodeTypes[to]) continue + if (from === typeId) add(outgoing, rel.id, to) + if (to === typeId) add(incoming, rel.id, from) + } + } + return { parents, children, outgoing, incoming } +} diff --git a/apps/studio/src/renderer/src/studio.css b/apps/studio/src/renderer/src/studio.css index d0e049c1..68a17d6c 100644 --- a/apps/studio/src/renderer/src/studio.css +++ b/apps/studio/src/renderer/src/studio.css @@ -1154,6 +1154,10 @@ label.app-menu-row { cursor: pointer; } flex-direction: column; overflow: hidden; } +.mm-editor-modal.is-diagram { + width: min(1680px, 96vw); + height: 92vh; +} .mm-editor-modal-close { position: absolute; top: 8px; @@ -1501,6 +1505,280 @@ label.app-menu-row { cursor: pointer; } padding: 0; } .mm-link:hover { text-decoration: underline; } +/* ─── Metamodel editor: List / Diagram tabs ─────────────────────────── */ +.mm-tabs { + display: inline-flex; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); +} +.mm-tab { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 12px; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; +} +.mm-tab:hover { color: var(--text-primary); } +.mm-tab.active { + background: rgba(var(--accent-rgb), 0.16); + color: var(--text-primary); +} +.mm-card.focused { border-color: var(--accent); } +/* Diagram tab: header stays, the canvas takes the rest of the column. */ +.mm-editor-main.is-diagram { + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 0; +} +.mm-editor-main.is-diagram .mm-editor-header { margin-bottom: 0; } + +/* ─── Metamodel diagram ──────────────────────────────────────────────── */ +.mmd-canvas { + flex: 1; + min-height: 0; + margin: 0 -28px; + background: var(--bg-canvas); +} +.mmd-canvas.unfitted .react-flow__viewport { visibility: hidden; } +.mmd-canvas .react-flow__controls button { + background: var(--bg-toolbar); + border-bottom-color: var(--border-color); + fill: var(--text-primary); +} +.mmd-node { + box-sizing: border-box; + display: flex; + flex-direction: column; + background: var(--bg-toolbar); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s, border-color 0.15s; +} +.mmd-node:hover { border-color: var(--accent); } +.mmd-node.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.45); +} +.mmd-node.dim { opacity: 0.12; filter: grayscale(1); } +.mmd-category { + box-sizing: border-box; + border: 1px solid var(--border-color); + border-radius: 12px; + background: rgba(var(--accent-rgb), 0.035); +} +.react-flow__node-mmCategory { pointer-events: none !important; } +.mmd-category-label { + position: absolute; + top: 9px; + left: 14px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} +.mmd-handle { + opacity: 0; + pointer-events: none; +} +.mmd-node-header { + flex: 0 0 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; +} +.mmd-node-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mmd-node-tag { + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 1px 5px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.2); +} +.mmd-node-body { + padding: 8px 12px; + display: flex; + flex-direction: column; +} +.mmd-prop { + height: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + font-family: ui-monospace, monospace; + color: var(--text-primary); +} +.mmd-prop-key { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mmd-prop-type { color: var(--text-muted); flex-shrink: 0; } +.mmd-prop-empty { + color: var(--text-muted); + font-family: inherit; + font-style: italic; +} +.mmd-req { color: var(--danger); margin-left: 2px; } +.mmd-node-chips { + flex: 0 0 26px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 10px; + border-top: 1px dashed var(--border-color); + overflow: hidden; +} +.mmd-chip { + font-size: 10px; + white-space: nowrap; + color: var(--text-secondary, var(--text-primary)); + border: 1px solid; + border-radius: 9px; + padding: 0 7px; + line-height: 16px; +} +.mmd-edge { transition: opacity 0.15s; } +.mmd-edge.dim { opacity: 0.03; } +.mmd-edge-label { + position: absolute; + pointer-events: none; + font-size: 10px; + font-weight: 500; + padding: 0 4px; + border-radius: 3px; + background: var(--bg-canvas); + white-space: nowrap; + transition: opacity 0.15s; +} +.mmd-edge-label.dim { opacity: 0; } +.mmd-edge-label.hovered { z-index: 5; outline: 1px solid currentColor; } +.mmd-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border-color); +} +.mmd-legend-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin: 10px 0 4px; +} +.mmd-legend-head span { flex: 1; } +.mmd-legend-head .mm-link { align-self: center; text-transform: none; letter-spacing: 0; } +.mmd-legend-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 4px; + border-radius: 4px; + font-size: 11px; + color: var(--text-primary); + cursor: pointer; +} +.mmd-legend-row input { accent-color: var(--accent); margin: 0; } +.mmd-legend-row.active { background: rgba(var(--accent-rgb), 0.14); } +.mmd-legend-name { + background: none; + border: none; + padding: 0; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; +} +.mmd-legend-name:hover { color: var(--accent); } +.mmd-legend-note { + margin-top: 8px; + font-size: 10px; + line-height: 1.45; + color: var(--text-muted); +} +.mmd-status { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-toolbar); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 10px; +} + +/* ─── Metamodel diagram inspector (right column) ─────────────────────── */ +.mmi { display: flex; flex-direction: column; gap: 6px; } +.mmi .mm-section-label { margin-top: 10px; } +.mmi-back { margin-bottom: 4px; } +.mmi-head { display: flex; align-items: center; gap: 10px; } +.mmi-title { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.mmi-facts { font-size: 11px; color: var(--text-muted); line-height: 1.4; } +.mmi-edit { align-self: flex-start; margin-top: 4px; } +.mmi-chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } +.mmi-chips.indent { padding-left: 14px; } +.mmi-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--input-bg); + color: var(--text-primary); + cursor: pointer; +} +.mmi-chip:hover { border-color: var(--accent); } +.mmi-chip.static { cursor: default; color: var(--text-muted); font-style: italic; } +.mmi-chip.static:hover { border-color: var(--border-color); } +.mmi-swatch { width: 8px; height: 8px; border-radius: 2px; } +.mmi-rel-group { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; align-items: flex-start; } +.mmi-rel-name { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 0; + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; +} +.mmi-rel-name:hover { color: var(--accent); } +.mmi-rel-line { display: inline-block; width: 14px; height: 3px; border-radius: 2px; } +.mmi-rel-line.wide { width: 24px; } +.mmi-arrow { color: var(--text-muted); font-size: 11px; } +.mmi-props { display: flex; flex-direction: column; gap: 6px; } +.mmi-prop { font-size: 11px; color: var(--text-primary); } +.mmi-prop-key { font-family: ui-monospace, monospace; } +.mmi-prop-type { color: var(--text-muted); margin-left: 6px; } +.mmi-prop-sub { color: var(--text-muted); font-size: 10px; margin-top: 1px; } /* ─── Floating selection action bar ──────────────────────────────────── */ .sel-bar { position: fixed; diff --git a/apps/studio/tests/metamodelGraph.test.ts b/apps/studio/tests/metamodelGraph.test.ts new file mode 100644 index 00000000..84df14b8 --- /dev/null +++ b/apps/studio/tests/metamodelGraph.test.ts @@ -0,0 +1,180 @@ +/** + * The metamodel diagram's graph rules: how allowed parents and relation + * pairs become boxes, edges and chips, and that ELK lays every one of them out. + */ +import { describe, it, expect } from 'vitest' +import { builtInGovernanceMetamodel, type Metamodel } from '@radical/common/metamodel' +import type { SmartLayoutResult } from '@radical/layout/smartLayout' +import { + buildMetamodelGraph, + layoutMetamodelGraph, + smartLayoutInput, + typeNeighbourhood, + CONTAINS_EDGE, + type Rect, +} from '../src/renderer/src/components/metamodel/metamodelGraph' + +function tiny(): Metamodel { + const base = builtInGovernanceMetamodel() + const t = (id: string, allowedParents?: string[]) => ({ ...base.nodeTypes.system, id, label: id, allowedParents, properties: [] }) + return { + id: 'tiny', + name: 'Tiny', + nodeTypes: { a: t('a', ['a']), b: t('b', ['a', 'ghost']), c: t('c') }, + relationTypes: { + uses: { id: 'uses', label: 'Uses', allowedPairs: [{ from: 'b', to: 'c' }, { from: 'c', to: 'b' }, { from: 'b', to: 'c' }] }, + replaces: { id: 'replaces', label: 'Replaces', allowedPairs: [{ from: 'c', to: 'c' }] }, + dangling: { id: 'dangling', label: 'Dangling', allowedPairs: [{ from: 'a', to: 'ghost' }] }, + anything: { id: 'anything', label: 'Anything', allowedPairs: [] }, + }, + } +} + +describe('buildMetamodelGraph', () => { + it('draws containment parent → child and turns self-nesting into a flag', () => { + const g = buildMetamodelGraph(tiny()) + const contains = g.edges.filter((e) => e.type === CONTAINS_EDGE) + expect(contains.map((e) => [e.source, e.target])).toEqual([['a', 'b']]) + expect(g.nodes.find((n) => n.id === 'a')!.nestsInSelf).toBe(true) + }) + + it('merges a pair allowed both ways into one two-headed edge', () => { + const uses = buildMetamodelGraph(tiny()).edges.filter((e) => e.type === 'uses') + expect(uses).toHaveLength(1) + expect(uses[0]).toMatchObject({ source: 'b', target: 'c', bidirectional: true }) + }) + + it('turns self pairs into chips and skips pairs naming unknown types', () => { + const g = buildMetamodelGraph(tiny()) + expect(g.nodes.find((n) => n.id === 'c')!.selfRelations.map((r) => r.id)).toEqual(['replaces']) + expect(g.edges.some((e) => e.type === 'dangling' || e.type === 'replaces')).toBe(false) + }) + + it('lists any-pair relations separately', () => { + expect(buildMetamodelGraph(tiny()).anyPairRelations.map((r) => r.id)).toEqual(['anything']) + }) + + it('honours the filter', () => { + const g = buildMetamodelGraph(tiny(), { showContainment: false, showProperties: true, hiddenRelations: new Set(['uses']) }) + expect(g.edges).toEqual([]) + }) + + it('draws every relation between the two types it connects', () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel()) + const constrains = g.edges.filter((e) => e.type === 'constrains') + expect(constrains).toHaveLength(24) + const ids = new Set(g.nodes.map((n) => n.id)) + for (const e of g.edges) expect(ids.has(e.source) && ids.has(e.target)).toBe(true) + }) + + it('groups types by the palette categories, custom types last', () => { + const mm = builtInGovernanceMetamodel() + mm.nodeTypes.custom1 = { ...mm.nodeTypes.system, id: 'custom1', label: 'Custom 1', builtin: false } + const g = buildMetamodelGraph(mm) + expect(g.categories.map((c) => c.label)).toEqual(['C4', 'Domain', 'Governance', 'Requirements', 'UX', 'Other', 'Custom']) + expect(g.categories.find((c) => c.label === 'Requirements')!.members).toEqual(['need', 'requirement', 'scenario']) + expect(g.nodes.find((n) => n.id === 'adr')!.category).toBe('category:governance') + }) + + it('with containment hidden, still links a type no relation type reaches to its allowed parents', () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel(), { showContainment: false, showProperties: false, hiddenRelations: new Set() }) + expect(g.edges.filter((e) => e.type === CONTAINS_EDGE).map((e) => e.id).sort()).toEqual([ + 'contains:domain>blueprint', + 'contains:group>blueprint', + ]) + const linked = new Set(g.edges.flatMap((e) => [e.source, e.target])) + expect(g.nodes.filter((n) => !linked.has(n.id)).map((n) => n.id)).toEqual([]) + }) + + it('with a narrowed filter, keeps only the types the shown edges connect', () => { + const mm = builtInGovernanceMetamodel() + const only = (id: string) => ({ + showContainment: false, + showProperties: false, + hiddenRelations: new Set(Object.keys(mm.relationTypes).filter((r) => r !== id)), + }) + // Satisfies: the eight C4 element types → Requirement. No containment rescue. + const satisfies = buildMetamodelGraph(mm, only('satisfies')) + expect(satisfies.nodes.map((n) => n.id).sort()).toEqual( + ['component', 'container', 'database', 'domain', 'person', 'queue', 'requirement', 'system', 'webapp'], + ) + expect(satisfies.edges.every((e) => e.type === 'satisfies')).toBe(true) + expect(satisfies.categories.map((c) => c.label)).toEqual(['C4', 'Domain', 'Requirements']) + // A relation drawn only as a chip (ADR supersedes ADR) still keeps its type. + expect(buildMetamodelGraph(mm, only('supersedes')).nodes.map((n) => n.id)).toEqual(['adr']) + // Nothing shown at all: every type stays, without edges. + const none = buildMetamodelGraph(mm, { showContainment: false, showProperties: false, hiddenRelations: new Set(Object.keys(mm.relationTypes)) }) + expect(none.edges).toEqual([]) + expect(none.nodes).toHaveLength(16) + }) + + it('draws containment to every allowed child, Group included', () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel()) + const fromGroup = g.edges.filter((e) => e.type === CONTAINS_EDGE && e.source === 'group') + expect(fromGroup).toHaveLength(15) + }) + + it('grows a box for self-relation chips', () => { + const g = buildMetamodelGraph(tiny()) + const [b, c] = ['b', 'c'].map((id) => g.nodes.find((n) => n.id === id)!) + expect(c.height).toBeGreaterThan(b.height) + }) +}) + +describe('layoutMetamodelGraph (Smart Layout)', () => { + const overlap = (a: Rect, b: Rect): boolean => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height + + it('keeps types apart, inside their category frame, frames apart, and routes every edge', async () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel(), { showContainment: true, showProperties: false, hiddenRelations: new Set() }) + const layout = await layoutMetamodelGraph(g) + const box = (id: string): Rect => { + const n = g.nodes.find((x) => x.id === id)! + return { ...layout.positions[id], width: n.width, height: n.height } + } + for (let i = 0; i < g.nodes.length; i++) + for (let j = i + 1; j < g.nodes.length; j++) + expect(overlap(box(g.nodes[i].id), box(g.nodes[j].id)), `${g.nodes[i].id} overlaps ${g.nodes[j].id}`).toBe(false) + for (const n of g.nodes) { + const f = layout.frames[n.category] + const b = box(n.id) + expect(b.x >= f.x && b.y >= f.y && b.x + b.width <= f.x + f.width && b.y + b.height <= f.y + f.height, `${n.id} outside its frame`).toBe(true) + } + const frames = Object.entries(layout.frames) + for (let i = 0; i < frames.length; i++) + for (let j = i + 1; j < frames.length; j++) + expect(overlap(frames[i][1], frames[j][1]), `${frames[i][0]} overlaps ${frames[j][0]}`).toBe(false) + for (const e of g.edges) expect(layout.routes[e.id]?.path).toMatch(/^M/) + }, 30_000) + + it('shows Smart Layout\'s positions unchanged (child offsets added to their group)', async () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel()) + const positions: Record = {} + g.categories.forEach((c, i) => { + positions[c.id] = { x: i * 1000, y: 50 } + c.members.forEach((id, j) => (positions[id] = { x: 30, y: 120 + j * 100 })) + }) + const run = async () => ({ winner: { positions } }) as unknown as SmartLayoutResult + const layout = await layoutMetamodelGraph(g, run) + expect(layout.positions.adr).toEqual({ x: 2000 + 30, y: 50 + 120 }) + expect(layout.positions.blueprint).toEqual({ x: 2000 + 30, y: 50 + 320 }) + }) + + it('hands Smart Layout a group per category with the types as leaves inside', () => { + const g = buildMetamodelGraph(builtInGovernanceMetamodel()) + const { nodes, relations } = smartLayoutInput(g) + expect(nodes['category:governance']).toMatchObject({ type: 'group' }) + expect(nodes.requirement).toMatchObject({ type: 'component', parentId: 'category:requirements', collapsed: false }) + expect(Object.keys(relations)).toHaveLength(g.edges.length) + }) +}) + +describe('typeNeighbourhood', () => { + it('collects parents, children and relations both ways', () => { + const n = typeNeighbourhood(tiny(), 'b') + expect(n.parents).toEqual(['a']) + expect(n.children).toEqual([]) + expect(n.outgoing.get('uses')).toEqual(['c']) + expect(n.incoming.get('uses')).toEqual(['c']) + }) +}) diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index 84520d87..2955f440 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -232,3 +232,25 @@ preset on load. Still open: `packages/common/src/formats/mdFolder.ts` turns "zamówienie" into "zamo-wienie" (NFKD leaves the accent as a separate mark, which becomes a dash). Check whether fixing it renames files in existing folders. + +### Metamodel diagram (2026-10-06) + +Added in `feat/metamodel-diagram`: a Diagram tab in the metamodel editor +(`apps/studio/src/renderer/src/components/metamodel/`). Node types are boxes +in a frame per palette category (`NODE_TYPE_CATEGORIES`, now shared with the +Elements palette), containment and relation types are edges, placed by Smart +Layout and then settled by the live WebCoLa physics, as on the canvas. Still +open: + +1. **No dragging.** The canvas lets you drag nodes while the physics runs; + the diagram does not. `LiveColaLayout.drag` takes positions relative to + the cola group bounds, while the diagram keeps absolute ones. +2. **The physics has a time budget (4 s).** WebCoLa does not always converge + with nested groups (`known-issues.spec.ts`), so the diagram stops it + instead of waiting for convergence. +3. **Blueprint has no relation type.** In the Governance preset it can only + be placed inside a Domain or Group; nothing relates it to systems, ADRs + or requirements. +4. **Dense presets stay dense.** With every edge shown, Governance has ~96 + edges over 16 types; the legend filters and selection focus are the way + to read it. diff --git a/package-lock.json b/package-lock.json index 56500673..bd48e278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "@radical/common": "*", "@radical/host-bridge": "*", "@radical/hub-catalogue": "*", + "@radical/layout": "*", "@radical/node-files": "*", "@radical/ui": "*", "@types/react": "^18.2.0", diff --git a/packages/common/src/metamodel/categories.ts b/packages/common/src/metamodel/categories.ts new file mode 100644 index 00000000..13dd915a --- /dev/null +++ b/packages/common/src/metamodel/categories.ts @@ -0,0 +1,28 @@ +// ─── Node type categories ────────────────────────────────────────────────── +// +// How node types are grouped for people: the Elements palette's sections and +// the metamodel diagram's frames. Types not listed here (custom types) fall +// into CUSTOM_CATEGORY. + +export interface NodeTypeCategory { + id: string + label: string + /** Node type ids, in display order. */ + types: string[] +} + +export const NODE_TYPE_CATEGORIES: readonly NodeTypeCategory[] = [ + { id: 'c4', label: 'C4', types: ['person', 'system', 'container', 'component', 'database', 'webapp', 'queue'] }, + { id: 'domain', label: 'Domain', types: ['domain'] }, + { id: 'governance', label: 'Governance', types: ['adr', 'fitness-fn', 'blueprint'] }, + { id: 'requirements', label: 'Requirements', types: ['need', 'requirement', 'scenario'] }, + { id: 'ux', label: 'UX', types: ['mockup'] }, + { id: 'other', label: 'Other', types: ['group'] }, +] + +export const CUSTOM_CATEGORY: Omit = { id: 'custom', label: 'Custom' } + +export function nodeTypeCategory(typeId: string): Omit { + const cat = NODE_TYPE_CATEGORIES.find((c) => c.types.includes(typeId)) + return cat ? { id: cat.id, label: cat.label } : CUSTOM_CATEGORY +} diff --git a/packages/common/src/metamodel/index.ts b/packages/common/src/metamodel/index.ts index 8652cc32..88a37aeb 100644 --- a/packages/common/src/metamodel/index.ts +++ b/packages/common/src/metamodel/index.ts @@ -7,6 +7,7 @@ // • validate.ts — validateModel + Issue // • ears.ts — EARS sentence compose/parse for the Requirement node type // • wizard.ts — step / field / relation helpers behind the node wizard +// • categories.ts — palette / diagram grouping of node types (C4, Governance, …) // • presets/ — the built-in C4, C4+DDD, and C4+DDD+Governance presets export * from './types' @@ -14,4 +15,5 @@ export * from './lookup' export * from './validate' export * from './ears' export * from './wizard' +export * from './categories' export * from './presets' diff --git a/packages/ui/src/components/RightPanel.tsx b/packages/ui/src/components/RightPanel.tsx index 814ac233..07939899 100644 --- a/packages/ui/src/components/RightPanel.tsx +++ b/packages/ui/src/components/RightPanel.tsx @@ -1,7 +1,7 @@ import React, { ChangeEvent, useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useDiagramStore, nodeEffectivelyCollapsedInView } from '../store/diagramStore' import { C4ElementType, NODE_COLORS, TYPE_LABELS, TYPE_ICON_PATHS, NODE_FG, isContainerType } from '@radical/common/c4' -import { resolveEarsSubject } from '@radical/common/metamodel' +import { resolveEarsSubject, NODE_TYPE_CATEGORIES, CUSTOM_CATEGORY } from '@radical/common/metamodel' import { EarsQuickEntry } from './EarsQuickEntry' import type { HubImportRecord } from '../store/hubStore' import { MockupWireframe } from './MockupWireframe' @@ -62,14 +62,7 @@ const PALETTE_SUBLABELS: Record = { const PALETTE_ORDER = ['domain', 'group', 'person', 'system', 'container', 'component', 'database', 'webapp', 'queue'] // Groups shown in the palette. Types not listed here fall into "Custom". -const PALETTE_GROUPS: { label: string; types: string[] }[] = [ - { label: 'C4', types: ['person', 'system', 'container', 'component', 'database', 'webapp', 'queue'] }, - { label: 'Domain', types: ['domain'] }, - { label: 'Governance', types: ['adr', 'fitness-fn', 'blueprint'] }, - { label: 'Requirements',types: ['need', 'requirement', 'scenario'] }, - { label: 'UX', types: ['mockup'] }, - { label: 'Other', types: ['group'] }, -] +const PALETTE_GROUPS = NODE_TYPE_CATEGORIES function PaletteItem({ typeId, label, sublabel, color, iconPath }: { typeId: string @@ -1189,7 +1182,7 @@ export function LeftPanel({ mode = 'designer', readOnly = false, collapsed = fal .filter(g => g.items.length > 0) if (customTypes.length > 0) { - groups.push({ label: 'Custom', items: customTypes.map(makeItem) }) + groups.push({ label: CUSTOM_CATEGORY.label, items: customTypes.map(makeItem) }) } return groups From c7f3bd3ddf75b6d5c973b78e427af39c4b424af4 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Tue, 6 Oct 2026 13:16:45 +0200 Subject: [PATCH 2/2] fix(studio): keep metamodel diagram boxes and edges drawn on slow machines ReactFlow 11 rebuilds its node state from the node objects it is given and does not keep the measured size. The diagram hands it new nodes every physics frame without width/height, so ReactFlow hid the boxes and skipped their edges until it measured them again; on a slow CI runner the last frame stayed that way (16 nodes, 0 edges). Every node now carries the size the layout already knows. - e2e: a CPU-throttled run (6x) reproduces the bug; it fails without the fix. - e2e: read the overview zoom only after the post-physics fit has finished. Co-Authored-By: Claude Opus 5.5 --- .../tests/studio/metamodel-diagram.spec.ts | 24 ++++++++++++++++++- .../components/metamodel/MetamodelDiagram.tsx | 9 +++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/e2e/tests/studio/metamodel-diagram.spec.ts b/apps/e2e/tests/studio/metamodel-diagram.spec.ts index 1230300a..47363e24 100644 --- a/apps/e2e/tests/studio/metamodel-diagram.spec.ts +++ b/apps/e2e/tests/studio/metamodel-diagram.spec.ts @@ -75,7 +75,15 @@ test('selecting a type shows its rules; Edit opens it in the list', async ({ pag await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 }) const zoom = async (): Promise => diagram.locator('.react-flow__viewport').evaluate((el) => Number(/scale\(([\d.]+)\)/.exec((el as HTMLElement).style.transform)?.[1])) - const overview = await zoom() + // The fit after the physics animates; read the overview once it has stopped. + let overview = await zoom() + await expect + .poll(async () => { + const prev = overview + overview = await zoom() + return overview === prev + }, { intervals: [150] }) + .toBe(true) // Selecting a type frames it with what it is connected to. await diagram.getByTestId('rf__node-blueprint').click() @@ -109,3 +117,17 @@ test('selecting a type shows its rules; Edit opens it in the list', async ({ pag await expect(editor.getByRole('tab', { name: 'List' })).toHaveAttribute('aria-selected', 'true') await expect(editor.locator('.mm-card.focused input.mm-input').first()).toHaveValue('Requirement') }) + +// CI runners are slow: there the last frame of the physics used to leave the +// type boxes unmeasured, hidden and without edges. A throttled CPU reproduces it. +test('the diagram stays drawn on a slow machine', async ({ page, studio }) => { + await studio.open('v-context', 'metamodel') + const cdp = await page.context().newCDPSession(page) + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 }) + const editor = page.getByRole('dialog', { name: 'Metamodel editor' }) + await editor.getByRole('tab', { name: 'Diagram' }).click() + const diagram = editor.locator('.mmd-canvas') + await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 60_000 }) + await expect(diagram.locator('.react-flow__edge')).toHaveCount(96) + await expect(diagram.getByTestId('rf__node-requirement')).toBeVisible() +}) diff --git a/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx b/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx index 61ab8722..0ebd3754 100644 --- a/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx +++ b/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx @@ -527,6 +527,11 @@ function DiagramInner({ metamodel, filter, selection, onSelect, onEdit }: Props) height: inner.height + CATEGORY_PADDING.top + CATEGORY_PADDING.bottom, } } + // Every node carries its size: ReactFlow rebuilds its node state from + // these objects (it does not keep the measured size), and a node without + // one is hidden along with its edges until it is measured again — which, + // with the physics replacing the nodes every frame, can leave the last + // frame blank on a slow machine. return [ // Frames first and lowest so the types and edges paint over them. ...layout.graph.categories.map((category) => { @@ -535,6 +540,8 @@ function DiagramInner({ metamodel, filter, selection, onSelect, onEdit }: Props) id: category.id, type: 'mmCategory', position: frame ? { x: frame.x, y: frame.y } : { x: 0, y: 0 }, + width: (frame ?? frames[category.id]).width, + height: (frame ?? frames[category.id]).height, hidden: !frame, draggable: false, connectable: false, @@ -547,6 +554,8 @@ function DiagramInner({ metamodel, filter, selection, onSelect, onEdit }: Props) id: n.id, type: 'mmType', position: positions[n.id] ?? { x: 0, y: 0 }, + width: n.width, + height: n.height, hidden: onlyFocus && !focus!.nodes.has(n.id), draggable: false, connectable: false,