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..47363e24 --- /dev/null +++ b/apps/e2e/tests/studio/metamodel-diagram.spec.ts @@ -0,0 +1,133 @@ +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])) + // 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() + 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') +}) + +// 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/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..0ebd3754 --- /dev/null +++ b/apps/studio/src/renderer/src/components/metamodel/MetamodelDiagram.tsx @@ -0,0 +1,657 @@ +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, + } + } + // 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) => { + const frame = frameOf(category) + return { + 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, + 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 }, + width: n.width, + height: n.height, + 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