qgraphflow 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.claude-plugin/plugin.json +13 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/.cursor-plugin/plugin.json +9 -0
  6. package/.qoder-plugin/plugin.json +9 -0
  7. package/LICENSE +21 -0
  8. package/README.md +262 -0
  9. package/THIRD_PARTY_NOTICES.md +190 -0
  10. package/bin/qgraphflow.mjs +17 -0
  11. package/docs/clients.de.md +83 -0
  12. package/docs/clients.es.md +83 -0
  13. package/docs/clients.ja.md +83 -0
  14. package/docs/clients.md +83 -0
  15. package/docs/clients.pt.md +83 -0
  16. package/docs/clients.ru.md +83 -0
  17. package/docs/clients.zh-CN.md +83 -0
  18. package/docs/readme/README.de.md +262 -0
  19. package/docs/readme/README.es.md +262 -0
  20. package/docs/readme/README.ja.md +262 -0
  21. package/docs/readme/README.pt.md +262 -0
  22. package/docs/readme/README.ru.md +262 -0
  23. package/docs/readme/README.zh-CN.md +264 -0
  24. package/examples/order-flow.graph.json +94 -0
  25. package/package.json +61 -0
  26. package/skills/q-flow/SKILL.md +69 -0
  27. package/skills/q-flow/agents/openai.yaml +5 -0
  28. package/skills/q-flow/assets/layout-dist/ELK-LICENSE.md +264 -0
  29. package/skills/q-flow/assets/layout-dist/worker.mjs +24 -0
  30. package/skills/q-flow/assets/viewer/package.json +22 -0
  31. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +43 -0
  32. package/skills/q-flow/assets/viewer/src/diagrams/card.js +21 -0
  33. package/skills/q-flow/assets/viewer/src/diagrams/class.js +52 -0
  34. package/skills/q-flow/assets/viewer/src/diagrams/dataflow.js +19 -0
  35. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +41 -0
  36. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +174 -0
  37. package/skills/q-flow/assets/viewer/src/diagrams/er.js +34 -0
  38. package/skills/q-flow/assets/viewer/src/diagrams/flowchart.js +37 -0
  39. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +28 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +38 -0
  41. package/skills/q-flow/assets/viewer/src/diagrams/state.js +91 -0
  42. package/skills/q-flow/assets/viewer/src/diagrams/usecase.js +28 -0
  43. package/skills/q-flow/assets/viewer/src/edge-routing.js +596 -0
  44. package/skills/q-flow/assets/viewer/src/export-svg.js +90 -0
  45. package/skills/q-flow/assets/viewer/src/graph-validation.js +286 -0
  46. package/skills/q-flow/assets/viewer/src/i18n-messages.json +1314 -0
  47. package/skills/q-flow/assets/viewer/src/i18n.js +14 -0
  48. package/skills/q-flow/assets/viewer/src/layout-measure.js +55 -0
  49. package/skills/q-flow/assets/viewer/src/layout-quality.js +164 -0
  50. package/skills/q-flow/assets/viewer/src/layout-spacing.js +12 -0
  51. package/skills/q-flow/assets/viewer/src/node-svg.js +28 -0
  52. package/skills/q-flow/assets/viewer/src/radix-colors.js +47 -0
  53. package/skills/q-flow/assets/viewer/src/sequence-executions.js +140 -0
  54. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +208 -0
  55. package/skills/q-flow/assets/viewer/src/session-graph.js +43 -0
  56. package/skills/q-flow/assets/viewer/src/text-layout.js +126 -0
  57. package/skills/q-flow/assets/viewer/src/visual-style.js +158 -0
  58. package/skills/q-flow/assets/viewer-dist/index.html +291 -0
  59. package/skills/q-flow/references/acceptance.md +11 -0
  60. package/skills/q-flow/references/evidence-sources.md +38 -0
  61. package/skills/q-flow/references/graph-common.md +54 -0
  62. package/skills/q-flow/references/graph-schema.md +214 -0
  63. package/skills/q-flow/references/guided-intake.md +100 -0
  64. package/skills/q-flow/references/types/architecture.md +41 -0
  65. package/skills/q-flow/references/types/class.md +40 -0
  66. package/skills/q-flow/references/types/dataflow.md +41 -0
  67. package/skills/q-flow/references/types/deployment.md +37 -0
  68. package/skills/q-flow/references/types/er.md +36 -0
  69. package/skills/q-flow/references/types/flowchart.md +47 -0
  70. package/skills/q-flow/references/types/sequence.md +74 -0
  71. package/skills/q-flow/references/types/state.md +44 -0
  72. package/skills/q-flow/references/types/usecase.md +39 -0
  73. package/skills/q-flow/references/viewer-development.md +258 -0
  74. package/skills/q-flow/references/visual-contract.md +54 -0
  75. package/skills/q-flow/scripts/compile-layout.mjs +565 -0
  76. package/skills/q-flow/scripts/compile-sequence.mjs +112 -0
  77. package/skills/q-flow/scripts/generate-viewer.mjs +126 -0
  78. package/skills/q-flow/scripts/validate-graph.mjs +278 -0
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "qgraphflow-viewer",
3
+ "private": true,
4
+ "version": "0.0.6",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "node build-layout.mjs && vite build",
8
+ "dev": "vite"
9
+ },
10
+ "dependencies": {
11
+ "@xyflow/react": "12.11.5",
12
+ "d3-force": "3.0.0",
13
+ "react": "19.2.8",
14
+ "react-dom": "19.2.8"
15
+ },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-react": "6.1.1",
18
+ "elkjs": "0.11.0",
19
+ "vite": "8.2.2",
20
+ "vite-plugin-singlefile": "2.3.3"
21
+ }
22
+ }
@@ -0,0 +1,43 @@
1
+ import { genericCard } from './card.js';
2
+ import { cylinder, polygonAnchor, rectAnchor, roundedRectAnchor, rectangle, HEXAGON, polygon } from './drawing.js';
3
+
4
+ const OCTAGON = [[.06, 0], [.94, 0], [1, .16], [1, .84], [.94, 1], [.06, 1], [0, .84], [0, .16]];
5
+ const SLANT = [[.08, 0], [1, 0], [.92, 1], [0, 1]];
6
+
7
+ export function architectureOutline(node, x, y) {
8
+ const { width: w, height: h } = node.size;
9
+ if (node.kind === 'external') return polygon(node, x, y, HEXAGON);
10
+ if (node.kind === 'security') return rectangle(node, x, y, 22);
11
+ if (node.kind === 'failure') return polygon(node, x, y, OCTAGON);
12
+ if (node.kind === 'config') return polygon(node, x, y, SLANT);
13
+ if (node.kind === 'business') return rectangle(node, x, y, Math.min(28, h / 2));
14
+ const base = rectangle(node, x, y, ['service', 'component'].includes(node.kind) ? 16 : 10);
15
+ // Notation stays in the card margins: text starts at x + 15, the chip row at y + 13, and the last text baseline sits
16
+ // 21px above the bottom.
17
+ if (node.kind === 'data') return [...base, ['path', { d: `M${x + 10} ${y}V${y + h}M${x + w - 10} ${y}V${y + h}`, fill: 'none' }]];
18
+ if (node.kind === 'database') return cylinder(node, x, y);
19
+ if (node.kind === 'framework') return [...base, ['path', { d: `M${x + 16} ${y + 6}H${x + w - 16}M${x + 16} ${y + h - 6}H${x + w - 16}`, fill: 'none', 'stroke-dasharray': '7 5' }]];
20
+ return base;
21
+ }
22
+
23
+ const anchor = (node, side, offset) => {
24
+ if (node.kind === 'external') return polygonAnchor(node, side, offset, HEXAGON);
25
+ if (node.kind === 'security') return roundedRectAnchor(node, side, offset, 22);
26
+ if (node.kind === 'failure') return polygonAnchor(node, side, offset, OCTAGON);
27
+ if (node.kind === 'config') return polygonAnchor(node, side, offset, SLANT);
28
+ if (node.kind === 'business') return roundedRectAnchor(node, side, offset, Math.min(28, node.size.height / 2));
29
+ if (node.kind === 'database') return rectAnchor(node, side, offset, { left: 12, right: 12, top: 2, bottom: 2 });
30
+ return rectAnchor(node, side, offset);
31
+ };
32
+
33
+ const contentInset = node => ['external', 'config', 'failure'].includes(node.kind) ? Math.min(28, node.size.width * .08) : 0;
34
+
35
+ export default {
36
+ dashedKinds: ['framework', 'optional'],
37
+ id: 'architecture', label: 'Architecture',
38
+ nodeKinds: ["external", "config", "framework", "security", "service", "business", "data", "failure", "system", "component", "database"],
39
+ groupKinds: ["runtime", "security", "ownership", "external"],
40
+ edgeKinds: ["request", "call", "data", "success", "failure", "framework", "optional", "depends"],
41
+ render: (node, ...args) => genericCard(node, ...args, architectureOutline, contentInset(node)), outline: architectureOutline, anchor,
42
+ cardLayout: true, contentInset,
43
+ };
@@ -0,0 +1,21 @@
1
+ import { translate } from '../i18n.js';
2
+ import { cardTextLayout, text, coreNode, dataKinds, kindLabels, paint } from './drawing.js';
3
+
4
+ export function genericCard(node, x, y, fill, stroke, palette, locale, outline, contentInset = 0) {
5
+ const { title, subtitle } = cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - contentInset * 2 } });
6
+ const titleText = title.lines.map((line, index) => text(x + 15 + contentInset, y + 68 + index * title.lineHeight, line, 'title')).join('');
7
+ const subtitleText = subtitle.lines.map((line, index) => text(x + 15 + contentInset, y + 68 + title.height + index * subtitle.lineHeight, line, 'body')).join('');
8
+ const core = coreNode(node);
9
+
10
+ // The icon plate is the identity chip: solid module color with a white glyph. Without a module it stays a quiet plate
11
+ // whose glyph takes the role tone. Titles never change color.
12
+ const chip = node.appearance?.chip, failure = node.kind === 'failure';
13
+ const tone = chip ? '#ffffff' : core ? palette.accent : dataKinds.has(node.kind) ? palette.data : failure ? palette.warn : palette.ink2;
14
+ const badge = chip ?? palette.badge;
15
+ const icon = dataKinds.has(node.kind) ? '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>'
16
+ : ['external', 'actor'].includes(node.kind) ? '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 18h4"/>'
17
+ : ['security', 'config'].includes(node.kind) ? '<path d="m12 2 8 4v6c0 5-8 10-8 10S4 17 4 12V6Z"/><path d="m8 12 3 3 5-6"/>'
18
+ : '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h8M8 17h4"/>';
19
+ const frameWidth = node.appearance?.moduleColor || node.appearance?.ring ? 1.5 : 1;
20
+ return `<g filter="url(#node-shadow)">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': frameWidth })}<rect x="${x + 15 + contentInset}" y="${y + 13}" width="25" height="25" rx="7" fill="${badge}"/><svg x="${x + 20 + contentInset}" y="${y + 18}" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="${tone}" stroke-width="${chip ? 1.75 : 1.5}">${icon}</svg>${text(x + 47 + contentInset, y + 29, translate(locale, kindLabels[node.kind] ?? node.kind), 'stereotype')}${titleText}${subtitleText}</g>`;
21
+ }
@@ -0,0 +1,52 @@
1
+ import { translate } from '../i18n.js';
2
+ import { text, fit, TYPOGRAPHY, nodeMetrics, rectangle, paint } from './drawing.js';
3
+ import { estimateLabelSize } from '../text-layout.js';
4
+
5
+ const outline = (node, x, y) => rectangle(node, x, y, 9);
6
+
7
+ function classNode(node, x, y, fill, stroke, palette) {
8
+ const attributes = node.attributes ?? [];
9
+ const methods = node.methods ?? [];
10
+ const { classHeaderHeight: header, classRowHeight: rowHeight } = nodeMetrics(node);
11
+ const attributeHeight = attributes.length ? attributes.length * rowHeight + 13 : 30;
12
+ const attrY = y + header;
13
+ const methodY = attrY + attributeHeight;
14
+ const stereotype = node.kind === 'interface' ? '«interface»' : node.kind === 'abstract' ? '«abstract»' : '';
15
+ const textWidth = node.size.width - 26;
16
+ const attrText = attributes.map((item, index) => text(x + 13, attrY + 6 + rowHeight / 2 + TYPOGRAPHY.body * .35 + index * rowHeight, fit(item, textWidth), 'member')).join('');
17
+ const methodText = methods.map((item, index) => text(x + 13, methodY + 6 + rowHeight / 2 + TYPOGRAPHY.body * .35 + index * rowHeight, fit(item, textWidth), 'member')).join('');
18
+ return `<g filter="url(#node-shadow)">${paint(outline(node, x, y), { fill: palette.surface2, stroke, 'stroke-width': node.appearance?.moduleColor || node.appearance?.ring ? 1.5 : 1 })}<path d="M${x + 9} ${y}H${x + node.size.width - 9}Q${x + node.size.width} ${y} ${x + node.size.width} ${y + 9}V${attrY}H${x}V${y + 9}Q${x} ${y} ${x + 9} ${y}Z" fill="${node.appearance?.header ?? fill}"/><rect x="${x + 1}" y="${methodY}" width="${node.size.width - 2}" height="${Math.max(0, node.size.height - header - attributeHeight - 1)}" fill="${palette.group}"/>${stereotype ? text(x + 13, y + 24, stereotype, 'stereotype') : ''}${text(x + 13, y + (stereotype ? 50 : TYPOGRAPHY.classHeader / 2 + 7), fit(node.label, textWidth), 'shape-title')}${node.subtitle ? text(x + 13, y + header - 10, fit(node.subtitle, textWidth), 'body') : ''}<path d="M${x} ${attrY}H${x + node.size.width}M${x} ${methodY}H${x + node.size.width}" stroke="${palette.rule}"/>${attrText}${methodText}</g>`;
19
+ }
20
+
21
+
22
+ export default {
23
+ dashedKinds: ['implementation', 'dependency'],
24
+ id: 'class', label: 'Class diagram',
25
+ nodeKinds: ["class", "interface", "abstract"],
26
+ groupKinds: [],
27
+ edgeKinds: ["association", "inheritance", "implementation", "composition", "aggregation", "dependency"],
28
+ render: classNode, outline, compartments: true,
29
+ undirected: ['association', 'composition', 'aggregation'],
30
+ markers: { inheritance: { end: 'triangle' }, implementation: { end: 'triangle' }, composition: { start: 'diamond-filled' }, aggregation: { start: 'diamond-open' }, dependency: { end: 'arrow-open' } },
31
+ edgeLabel: (edge, locale) => edge.label ?? (['composition', 'aggregation', 'inheritance', 'implementation'].includes(edge.kind) ? translate(locale, edge.kind) : ''),
32
+ validateEdge(edge, label, errors) {
33
+ for (const role of ['source', 'target']) {
34
+ const value = edge[`${role}Multiplicity`];
35
+ if (value === undefined) continue;
36
+ const range = typeof value === 'string' && /^(\d+)(?:\.\.(\d+|\*))?$/.exec(value);
37
+ if (value !== '*' && (!range || range[2] && range[2] !== '*' && Number(range[2]) < Number(range[1]))) errors.push(`${label}.${role}Multiplicity must be *, a non-negative integer, or an ascending range`);
38
+ if (['inheritance', 'implementation', 'dependency'].includes(edge.kind)) errors.push(`${label}.${role}Multiplicity is only supported on associations, aggregation and composition`);
39
+ }
40
+ },
41
+ endpointLabels(edge, points) {
42
+ return ['source', 'target'].flatMap(role => {
43
+ const label = edge[`${role}Multiplicity`];
44
+ if (label === undefined) return [];
45
+ const point = role === 'source' ? points[0] : points.at(-1), neighbor = role === 'source' ? points[1] : points.at(-2), size = estimateLabelSize(label, Infinity);
46
+ const horizontal = point.y === neighbor.y, direction = Math.sign(horizontal ? neighbor.x - point.x : neighbor.y - point.y);
47
+ const center = horizontal ? { x: point.x + direction * (24 + size.width / 2), y: point.y - 6 - size.height / 2 }
48
+ : { x: point.x + 6 + size.width / 2, y: point.y + direction * (24 + size.height / 2) };
49
+ return [{ role, label, labelPoint: center, labelBox: { x: center.x - size.width / 2, y: center.y - size.height / 2, ...size } }];
50
+ });
51
+ },
52
+ };
@@ -0,0 +1,19 @@
1
+ import { centeredTitle, rectangle, paint, rectAnchor, roundedRectAnchor } from './drawing.js';
2
+
3
+ const outline = (node, x, y) => node.kind === 'dataStore'
4
+ ? [['path', { d: `M${x + node.size.width} ${y}H${x}V${y + node.size.height}H${x + node.size.width}M${x + 12} ${y}V${y + node.size.height}` }]]
5
+ : rectangle(node, x, y, node.kind === 'process' ? 24 : 10);
6
+ const textArea = node => ({ width: node.size.width - 32, height: node.size.height - 20 });
7
+ function dataflowNode(node, x, y, fill, stroke) {
8
+ const area = textArea(node);
9
+ return `<g class="shape-label">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.2 })}${centeredTitle(x + node.size.width / 2, y + node.size.height / 2, node.label, area.width, area.height, node.subtitle)}</g>`;
10
+ }
11
+
12
+ export default {
13
+ id: 'dataflow', label: 'Data flow',
14
+ nodeKinds: ["external", "process", "dataStore"],
15
+ groupKinds: ["ownership", "external"],
16
+ edgeKinds: ["data"],
17
+ render: dataflowNode, outline, textArea,
18
+ anchor: (node, side, offset) => node.kind === 'process' ? roundedRectAnchor(node, side, offset, 24) : rectAnchor(node, side, offset),
19
+ };
@@ -0,0 +1,41 @@
1
+ import { genericCard } from './card.js';
2
+ import { cylinder, polygonAnchor, rectAnchor, roundedRectAnchor, rectangle, HEXAGON, polygon } from './drawing.js';
3
+
4
+ const CUBE = [[0, .1], [.06, 0], [1, 0], [1, .9], [.94, 1], [0, 1]];
5
+
6
+ export function deploymentOutline(node, x, y) {
7
+ const { width: w, height: h } = node.size;
8
+ // Notation stays clear of the card text: the stand sits under the last line, whose baseline is 21px above the bottom,
9
+ // and the container's stacked boxes take the top-right corner, opposite the chip.
10
+ if (node.kind === 'device') return [
11
+ ['rect', { x: x + 16, y, width: w - 32, height: h, rx: 10 }],
12
+ ['path', { d: `M${x + w / 2} ${y + h - 10}V${y + h - 5}M${x + w / 2 - 30} ${y + h - 5}H${x + w / 2 + 30}`, fill: 'none' }]
13
+ ];
14
+ if (node.kind === 'node') return [...polygon(node, x, y, CUBE), ['path', { d: `M${x} ${y + h * .1}L${x + w * .94} ${y + h * .1}L${x + w} ${y}M${x + w * .94} ${y + h * .1}V${y + h}`, fill: 'none' }]];
15
+ if (node.kind === 'container') return [...rectangle(node, x, y, 10), ['rect', { x: x + w - 30, y: y + 13, width: 16, height: 11, rx: 2, fill: 'none' }], ['rect', { x: x + w - 34, y: y + 17, width: 16, height: 11, rx: 2, fill: 'none' }]];
16
+ if (node.kind === 'artifact') return [['path', { d: `M${x} ${y}H${x + w - 28}L${x + w} ${y + 28}V${y + h}H${x}Z` }], ['path', { d: `M${x + w - 28} ${y}V${y + 28}H${x + w}`, fill: 'none' }]];
17
+ if (node.kind === 'database') return cylinder(node, x, y);
18
+ if (node.kind === 'external') return polygon(node, x, y, HEXAGON);
19
+ if (node.kind === 'service') return rectangle(node, x, y, 16);
20
+ return rectangle(node, x, y, 10);
21
+ }
22
+
23
+ const anchor = (node, side, offset) => {
24
+ if (node.kind === 'device') return rectAnchor(node, side, offset, { left: 16, right: 16, top: 0, bottom: 0 });
25
+ if (node.kind === 'node') return polygonAnchor(node, side, offset, CUBE);
26
+ if (node.kind === 'database') return rectAnchor(node, side, offset, { left: 12, right: 12, top: 2, bottom: 2 });
27
+ if (node.kind === 'external') return polygonAnchor(node, side, offset, HEXAGON);
28
+ if (node.kind === 'service') return roundedRectAnchor(node, side, offset, 16);
29
+ return rectAnchor(node, side, offset);
30
+ };
31
+
32
+ const contentInset = node => node.kind === 'external' ? Math.min(28, node.size.width * .08) : node.kind === 'device' ? 12 : 0;
33
+
34
+ export default {
35
+ id: 'deployment', label: 'Deployment',
36
+ nodeKinds: ["device", "node", "container", "artifact", "service", "database", "external"],
37
+ groupKinds: ["host", "network", "cluster", "namespace"],
38
+ edgeKinds: ["deploy", "network", "depends"],
39
+ render: (node, ...args) => genericCard(node, ...args, deploymentOutline, contentInset(node)), outline: deploymentOutline, anchor,
40
+ cardLayout: true, contentInset,
41
+ };
@@ -0,0 +1,174 @@
1
+ import { cardTextLayout, layoutText, groupHeadingLayout } from '../text-layout.js';
2
+ import { TYPOGRAPHY, isCore as coreNode, dataKinds, kindLabels, nodeMetrics } from '../visual-style.js';
3
+ export { cardTextLayout, layoutText, TYPOGRAPHY, coreNode, dataKinds, kindLabels, nodeMetrics };
4
+
5
+ export function groupHeadingSvg(group, x = 0, y = 0) {
6
+ return groupHeadingLayout(group).lines.map((line, i) => text(x + 16, y + 26 + i * 22, line, 'group')).join('');
7
+ }
8
+
9
+ export function groupFrameSvg(group, appearance, x = 0, y = 0) {
10
+ const radius = ['loop', 'par'].includes(group.kind) ? 5 : 14;
11
+ return `<rect class="boundary-frame" x="${x}" y="${y}" width="${group.size.width}" height="${group.size.height}" rx="${radius}" fill="${appearance.fill}" stroke="${appearance.stroke}" stroke-width="1"/>`;
12
+ }
13
+
14
+ export function escapeXml(value = '') {
15
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
16
+ }
17
+
18
+
19
+ export function fit(value, width) {
20
+ return { value: String(value ?? ''), width };
21
+ }
22
+
23
+
24
+ export function text(x, y, value, className, extra = '') {
25
+ const fitted = value && typeof value === 'object' && 'width' in value;
26
+ const fontSize = Number(extra.match(/font-size:([\d.]+)px/)?.[1]) || {
27
+ title: TYPOGRAPHY.title, 'shape-title': TYPOGRAPHY.title, 'participant-title': TYPOGRAPHY.title,
28
+ body: TYPOGRAPHY.body, 'field-name': TYPOGRAPHY.body, 'field-type': TYPOGRAPHY.body,
29
+ member: TYPOGRAPHY.body, 'entity-title': TYPOGRAPHY.title
30
+ }[className] || TYPOGRAPHY.small;
31
+ if (value && typeof value === 'object' && 'runs' in value) {
32
+ const spans = value.runs.map(run => { const color = value.colors[run.role]; return color ? `<tspan style="fill:${color}">${escapeXml(run.text)}</tspan>` : escapeXml(run.text); }).join('');
33
+ return `<text x="${x}" y="${y}" class="${className}"${extra}>${spans}</text>`;
34
+ }
35
+ let content = fitted ? value.value : value;
36
+ if (fitted && layoutText(content, Infinity, fontSize).width > value.width) {
37
+ const available = Math.max(0, value.width - layoutText('…', Infinity, fontSize).width);
38
+ content = available ? (layoutText(content, available, fontSize).lines[0] ?? '') + '…' : '…';
39
+ }
40
+ return `<text x="${x}" y="${y}" class="${className}"${extra}>${escapeXml(content)}</text>`;
41
+ }
42
+
43
+
44
+ export function centeredTitle(cx, cy, value, width, height = Infinity, subtitle = '') {
45
+ if (width <= 0 || height <= 0) return '';
46
+ const title = layoutText(value, width, TYPOGRAPHY.title, 29);
47
+ const body = layoutText(subtitle, width, TYPOGRAPHY.body, 23.2);
48
+ const titleCount = Math.min(title.lines.length, Math.max(1, Math.floor(height / title.lineHeight)));
49
+ const bodyCount = Math.min(body.lines.length, Math.max(0, Math.floor((height - titleCount * title.lineHeight - 5) / body.lineHeight)));
50
+ const usedHeight = titleCount * title.lineHeight + (bodyCount ? 5 + bodyCount * body.lineHeight : 0);
51
+ const top = cy - usedHeight / 2;
52
+ const lines = (layout, count, y, className) => layout.lines.slice(0, count).map((line, index) =>
53
+ text(cx, y + index * layout.lineHeight, line + (index === count - 1 && count < layout.lines.length ? '…' : ''), className, ' text-anchor="middle"')).join('');
54
+ const markup = lines(title, titleCount, top + 22, 'shape-title')
55
+ + lines(body, bodyCount, top + titleCount * title.lineHeight + 5 + 18, 'body');
56
+ // Legacy tiny symbols may be shorter than one title line; only their text is scaled to the available height.
57
+ const scale = Math.min(1, height / usedHeight);
58
+ return scale < 1 ? `<g transform="translate(${cx} ${cy}) scale(${scale}) translate(${-cx} ${-cy})">${markup}</g>` : markup;
59
+ }
60
+
61
+
62
+ // Geometry is declared once by each diagram and painted by both node rendering and selection.
63
+ export function paint(outline, attributes = {}) {
64
+ return outline.map(([tag, geometry]) => `<${tag}${Object.entries({ ...geometry, ...(Object.keys(attributes).length ? { class: 'node-surface' } : {}), ...attributes, ...(geometry.fill === undefined ? {} : { fill: geometry.fill }) }).map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')}/>`).join('');
65
+ }
66
+ export const rectangle = (node, x, y, radius = 10, height = node.size.height) => [['rect', { x, y, width: node.size.width, height, rx: radius }]];
67
+ // Flat caps keep the cylinder above the card's chip row (y + 13) and well below its last text baseline (h - 21); the
68
+ // 2px top and bottom insets are where edges attach.
69
+ export const cylinder = (node, x, y) => {
70
+ const { width: w, height: h } = node.size, ry = 5, top = y + 2 + ry, bottom = y + h - 2 - ry;
71
+ return [
72
+ ['path', { d: `M${x + 12} ${top}Q${x + 12} ${y + 2} ${x + w / 2} ${y + 2}T${x + w - 12} ${top}V${bottom}Q${x + w - 12} ${y + h - 2} ${x + w / 2} ${y + h - 2}T${x + 12} ${bottom}Z` }],
73
+ ['ellipse', { cx: x + w / 2, cy: top, rx: w / 2 - 12, ry, fill: 'none' }]
74
+ ];
75
+ };
76
+ export const HEXAGON = [[.08, 0], [.92, 0], [1, .5], [.92, 1], [.08, 1], [0, .5]];
77
+ export const polygon = (node, x, y, points) => [['polygon', { points: points.map(([px, py]) => `${x + px * node.size.width},${y + py * node.size.height}`).join(' ') }]];
78
+ export const diamond = (node, x, y) => [['polygon', { points: `${x + node.size.width / 2},${y} ${x + node.size.width},${y + node.size.height / 2} ${x + node.size.width / 2},${y + node.size.height} ${x},${y + node.size.height / 2}` }]];
79
+ export const actor = (node, x, y) => {
80
+ const cx = x + node.size.width / 2;
81
+ return [['circle', { cx, cy: y + 13, r: 11 }], ['path', { d: `M${cx} ${y + 24}v29M${cx - 20} ${y + 35}h40M${cx} ${y + 53}l-17 23M${cx} ${y + 53}l17 23`, fill: 'none' }]];
82
+ };
83
+
84
+ const nodeCenter = node => ({ x: node.position.x + node.size.width / 2, y: node.position.y + node.size.height / 2 });
85
+ export function rectAnchor(node, side, offset = 0, inset = 0) {
86
+ const middle = nodeCenter(node);
87
+ const values = typeof inset === 'number' ? { left: inset, right: inset, top: inset, bottom: inset } : { left: 0, right: 0, top: 0, bottom: 0, ...inset };
88
+ if (side === 'left' || side === 'right') return {
89
+ x: node.position.x + (side === 'left' ? values.left : node.size.width - values.right),
90
+ y: middle.y + offset
91
+ };
92
+ return {
93
+ x: middle.x + offset,
94
+ y: node.position.y + (side === 'top' ? values.top : node.size.height - values.bottom)
95
+ };
96
+ }
97
+
98
+ export function roundedRectAnchor(node, side, offset = 0, radius = 10) {
99
+ const { x, y } = node.position, { width, height } = node.size;
100
+ const middle = nodeCenter(node), r = Math.max(0, Math.min(radius, width / 2, height / 2));
101
+ if (side === 'left' || side === 'right') {
102
+ const py = middle.y + offset;
103
+ if (py < y || py > y + height) return rectAnchor(node, side, offset);
104
+ const circleY = py < y + r ? y + r : py > y + height - r ? y + height - r : py;
105
+ const curve = Math.sqrt(Math.max(0, r * r - (py - circleY) ** 2));
106
+ return { x: side === 'left' ? x + r - curve : x + width - r + curve, y: py };
107
+ }
108
+ const px = middle.x + offset;
109
+ if (px < x || px > x + width) return rectAnchor(node, side, offset);
110
+ const circleX = px < x + r ? x + r : px > x + width - r ? x + width - r : px;
111
+ const curve = Math.sqrt(Math.max(0, r * r - (px - circleX) ** 2));
112
+ return { x: px, y: side === 'top' ? y + r - curve : y + height - r + curve };
113
+ }
114
+
115
+ export function ellipseAnchor(node, side, offset = 0, insetX = 0, insetY = 0) {
116
+ const middle = nodeCenter(node), rx = Math.max(1, node.size.width / 2 - insetX), ry = Math.max(1, node.size.height / 2 - insetY);
117
+ if (side === 'left' || side === 'right') {
118
+ const py = middle.y + offset;
119
+ if (Math.abs(offset) > ry) return rectAnchor(node, side, offset);
120
+ const dx = rx * Math.sqrt(Math.max(0, 1 - ((py - middle.y) / ry) ** 2));
121
+ return { x: middle.x + (side === 'left' ? -dx : dx), y: py };
122
+ }
123
+ const px = middle.x + offset;
124
+ if (Math.abs(offset) > rx) return rectAnchor(node, side, offset);
125
+ const dy = ry * Math.sqrt(Math.max(0, 1 - ((px - middle.x) / rx) ** 2));
126
+ return { x: px, y: middle.y + (side === 'top' ? -dy : dy) };
127
+ }
128
+
129
+ export function polygonAnchor(node, side, offset = 0, normalizedPoints) {
130
+ const points = normalizedPoints.map(([px, py]) => ({ x: node.position.x + px * node.size.width, y: node.position.y + py * node.size.height }));
131
+ const middle = nodeCenter(node);
132
+ const horizontal = side === 'left' || side === 'right';
133
+ const fixed = horizontal ? middle.y + offset : middle.x + offset;
134
+ const hits = [];
135
+ for (let index = 0; index < points.length; index += 1) {
136
+ const a = points[index], b = points[(index + 1) % points.length];
137
+ const first = horizontal ? a.y : a.x, second = horizontal ? b.y : b.x;
138
+ if (fixed < Math.min(first, second) || fixed > Math.max(first, second) || first === second) continue;
139
+ const ratio = (fixed - first) / (second - first);
140
+ hits.push((horizontal ? a.x : a.y) + ratio * ((horizontal ? b.x : b.y) - (horizontal ? a.x : a.y)));
141
+ }
142
+ if (!hits.length) return rectAnchor(node, side, offset);
143
+ const value = side === 'left' || side === 'top' ? Math.min(...hits) : Math.max(...hits);
144
+ return horizontal ? { x: value, y: fixed } : { x: fixed, y: value };
145
+ }
146
+
147
+ export function actorAnchor(node, side, offset = 0, top = 0) {
148
+ const cx = node.position.x + node.size.width / 2, y = node.position.y + top;
149
+ if (side === 'left') return { x: cx - 20, y: y + 35 };
150
+ if (side === 'right') return { x: cx + 20, y: y + 35 };
151
+ if (side === 'top') return { x: cx, y: y + 2 };
152
+ return { x: cx + Math.sign(offset || 1) * Math.min(17, Math.abs(offset)), y: y + 76 };
153
+ }
154
+
155
+ export function svgStyles(palette, scope = '') {
156
+ return `${scope}.heading{font:650 24px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink};letter-spacing:-.5px}
157
+ ${scope}.meta{font:400 ${TYPOGRAPHY.small}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink3}}
158
+ ${scope}.group{font:650 ${TYPOGRAPHY.small}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink2};letter-spacing:.4px}
159
+ ${scope}[data-diagram-group-id]>.group{fill:${palette.ink2}}
160
+ ${scope}.group-kind{font:600 ${TYPOGRAPHY.small}px ui-monospace,monospace;fill:${palette.ink2};letter-spacing:.7px}
161
+ ${scope}.stereotype{font:600 ${TYPOGRAPHY.small}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink3};letter-spacing:.6px}
162
+ ${scope}.title,${scope}.participant-title{font:650 ${TYPOGRAPHY.title}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink}}
163
+ ${scope}.shape-title{font:650 ${TYPOGRAPHY.title}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink}}
164
+ ${scope}.body{font:400 ${TYPOGRAPHY.body}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink2}}
165
+ ${scope}.edge,${scope}.cardinality{font:500 ${TYPOGRAPHY.body}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink2}}
166
+ ${scope}.edge-bg{fill:${palette.surface};stroke:none}
167
+ ${scope}.field-key{font:700 ${TYPOGRAPHY.small}px ui-monospace,monospace;fill:${palette.accent}}
168
+ ${scope}.field-name{font:500 ${TYPOGRAPHY.body}px ui-monospace,monospace;fill:${palette.ink2}}
169
+ ${scope}.entity-title{font:650 ${TYPOGRAPHY.title}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink}}
170
+ ${scope}.entity-meta{font:500 ${TYPOGRAPHY.small}px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;fill:${palette.ink2}}
171
+ ${scope}.member{font:500 ${TYPOGRAPHY.body}px ui-monospace,monospace;fill:${palette.ink2}}
172
+ ${scope}.field-type{font:400 ${TYPOGRAPHY.body}px ui-monospace,monospace;fill:${palette.ink3}}
173
+ `;
174
+ }
@@ -0,0 +1,34 @@
1
+ import { translate } from '../i18n.js';
2
+ import { text, fit, layoutText, TYPOGRAPHY, nodeMetrics, rectangle, paint } from './drawing.js';
3
+
4
+ const outline = (node, x, y) => rectangle(node, x, y, 9);
5
+
6
+ function erNode(node, x, y, fill, stroke, palette, locale) {
7
+ const { erHeaderHeight: header, erRowHeight: rowHeight, erFontSize: fontSize } = nodeMetrics(node);
8
+ const subtitle = fit(node.subtitle ?? translate(locale, 'Entity'), node.size.width - 60);
9
+ const subtitleFont = node.subtitle ? TYPOGRAPHY.body : TYPOGRAPHY.small;
10
+ const rows = node.fields.map((field, index) => {
11
+ const rowY = y + header + index * rowHeight;
12
+ const baseline = rowY + rowHeight / 2 + fontSize * .35;
13
+ const typeWidth = Math.min(node.size.width * .55, layoutText(field.type, Infinity, fontSize).width);
14
+ return `${field.key === 'FK' ? `<rect x="${x + 1}" y="${rowY}" width="${node.size.width - 2}" height="${rowHeight}" fill="${palette.surface}"/>` : ''}${index ? `<path d="M${x} ${rowY}H${x + node.size.width}" stroke="${palette.ruleSoft}"/>` : ''}${field.key ? text(x + 10, baseline - 1, field.key, 'field-key', ` style="fill:${field.key === 'FK' ? palette.accent : palette.data}"`) : ''}${text(x + 45, baseline, fit(field.name, Math.max(1, node.size.width - 62 - typeWidth)), 'field-name', ` style="font-size:${fontSize}px"`)}${text(x + node.size.width - 10, baseline - 1, fit(field.type, typeWidth), 'field-type', ` text-anchor="end" style="font-size:${fontSize}px"`)}`;
15
+ }).join('');
16
+ return `<g filter="url(#node-shadow)">${paint(outline(node, x, y), { fill: palette.surface2, stroke, 'stroke-width': node.appearance?.moduleColor || node.appearance?.ring ? 1.5 : 1 })}<path d="M${x + 9} ${y}H${x + node.size.width - 9}Q${x + node.size.width} ${y} ${x + node.size.width} ${y + 9}V${y + header}H${x}V${y + 9}Q${x} ${y} ${x + 9} ${y}Z" fill="${node.appearance?.header ?? fill}"/><path d="M${x} ${y + header}H${x + node.size.width}" stroke="${palette.rule}"/>${text(x + 13, y + header * .43, fit(node.label, node.size.width - 26), 'entity-title', ` style="font-size:${Math.min(TYPOGRAPHY.title, header * .38)}px;fill:${palette.ink}"`)}${text(x + 13, y + header * .78, subtitle, 'entity-meta', ` style="font-size:${Math.min(subtitleFont, header * .23)}px;fill:${palette.ink2}"`)}${text(x + node.size.width - 13, y + header * .78, String(node.fields.length).padStart(2, '0'), 'entity-meta', ` text-anchor="end" style="font-size:${Math.min(subtitleFont, header * .23)}px;fill:${palette.ink2}"`)}${rows}</g>`;
17
+ }
18
+
19
+
20
+ export default {
21
+ id: 'er', label: 'ER diagram',
22
+ nodeKinds: ["entity"],
23
+ groupKinds: [],
24
+ edgeKinds: ["relationship"],
25
+ render: erNode, outline,
26
+ compartments: true, cardinalities: true, endpointStub: 28,
27
+ undirected: ['relationship'],
28
+ validateNode(node, label, errors) {
29
+ if (!Array.isArray(node?.fields) || node.fields.length === 0) errors.push(`${label}.fields must be a non-empty array for er`);
30
+ },
31
+ validateEdge(edge, label, errors) {
32
+ for (const field of ['sourceCardinality', 'targetCardinality']) if (!['1', '0..1', '*', '1..*', '0..*'].includes(edge?.[field])) errors.push(`${label}.${field} is unsupported`);
33
+ },
34
+ };
@@ -0,0 +1,37 @@
1
+ import { translate } from '../i18n.js';
2
+ import { centeredTitle, rectangle, diamond, paint, polygonAnchor, rectAnchor, roundedRectAnchor } from './drawing.js';
3
+
4
+ export const FLOW_SLANT = .12;
5
+ const outline = (node, x, y) => {
6
+ if (node.kind === 'decision') return diamond(node, x, y);
7
+ if (['input', 'output'].includes(node.kind)) return [['polygon', { points: `${x + node.size.width * FLOW_SLANT},${y} ${x + node.size.width},${y} ${x + node.size.width * (1 - FLOW_SLANT)},${y + node.size.height} ${x},${y + node.size.height}` }]];
8
+ return rectangle(node, x, y, ['start', 'end'].includes(node.kind) ? node.size.height / 2 : 10);
9
+ };
10
+ const anchor = (node, side, offset) => {
11
+ if (node.kind === 'decision') return polygonAnchor(node, side, offset, [[.5, 0], [1, .5], [.5, 1], [0, .5]]);
12
+ if (['input', 'output'].includes(node.kind)) return polygonAnchor(node, side, offset, [[FLOW_SLANT, 0], [1, 0], [1 - FLOW_SLANT, 1], [0, 1]]);
13
+ if (['start', 'end'].includes(node.kind)) return roundedRectAnchor(node, side, offset, node.size.height / 2);
14
+ return rectAnchor(node, side, offset);
15
+ };
16
+
17
+ const textArea = node => {
18
+ const { width: w, height: h } = node.size;
19
+ const inset = ['input', 'output'].includes(node.kind) ? w * FLOW_SLANT + 12 : ['start', 'end'].includes(node.kind) ? Math.min(h / 2, w / 4) : 18;
20
+ return { width: node.kind === 'decision' ? w / 2 - 8 : w - inset * 2, height: node.kind === 'decision' ? h / 2 - 8 : h - 12 };
21
+ };
22
+ function flowNode(node, x, y, fill, stroke) {
23
+ const { width: w, height: h } = node.size, area = textArea(node);
24
+ const label = centeredTitle(x + w / 2, y + h / 2, node.label, area.width, area.height, node.subtitle);
25
+ const inner = node.kind === 'subprocess' ? `<path d="M ${x + 11} ${y}V ${y + h}M ${x + w - 11} ${y}V ${y + h}" stroke="${stroke}"/>` : '';
26
+ return `<g class="shape-label">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.2 })}${inner}${label}</g>`;
27
+ }
28
+
29
+
30
+ export default {
31
+ id: 'flowchart', label: 'Flowchart',
32
+ nodeKinds: ["start", "end", "process", "decision", "input", "output", "subprocess"],
33
+ groupKinds: [],
34
+ edgeKinds: ["flow", "yes", "no", "success", "failure"],
35
+ render: flowNode, outline, anchor, textArea,
36
+ edgeLabel: (edge, locale) => edge.label ?? (['yes', 'no'].includes(edge.kind) ? translate(locale, edge.kind) : ''),
37
+ };
@@ -0,0 +1,28 @@
1
+ import architecture from './architecture.js';
2
+ import flowchart from './flowchart.js';
3
+ import sequence from './sequence.js';
4
+ import er from './er.js';
5
+ import deployment from './deployment.js';
6
+ import classDiagram from './class.js';
7
+ import state from './state.js';
8
+ import usecase from './usecase.js';
9
+ import dataflow from './dataflow.js';
10
+
11
+ // Add a diagram module here; navigation, validation, drawing and exports use this same list.
12
+ export const DIAGRAMS = [architecture, flowchart, sequence, er, deployment, classDiagram, state, usecase, dataflow];
13
+ export const DIAGRAM_TYPES = DIAGRAMS.map(diagram => diagram.id);
14
+ export const diagramLabels = Object.fromEntries(DIAGRAMS.map(diagram => [diagram.id, diagram.label]));
15
+ const byId = new Map(DIAGRAMS.map(diagram => [diagram.id, diagram]));
16
+ if (byId.size !== DIAGRAMS.length) throw new Error('Duplicate diagram type');
17
+ export const diagramTypeOf = graph => graph?.meta?.diagramType ?? 'architecture';
18
+ export const getDiagram = (type = 'architecture') => byId.get(type);
19
+ // Starting space budgets only; content bounds still determine the actual canvas and exports.
20
+ export const canvasBudgetFor = type => getDiagram(type).sequence ? null
21
+ : ['flowchart', 'state'].includes(type) ? { width: 1600, height: 2400 } : { width: 2400, height: 1600 };
22
+ export const hasArrow = (edge, type) => !(getDiagram(type).undirected ?? []).includes(edge.kind);
23
+ export const isDashed = (edge, type) => !(type === 'sequence' && edge.kind === 'sync') && (['framework', 'inference'].includes(edge.evidence) || (getDiagram(type).dashedKinds ?? []).includes(edge.kind));
24
+
25
+ // Diagrams whose labels carry roles (a state transition's trigger, guard and effect) name the palette color of each role.
26
+ export const labelRoleColors = (type, palette) => Object.fromEntries(Object.entries(getDiagram(type)?.labelRoleColors ?? {}).map(([role, key]) => [role, palette[key]]));
27
+
28
+ export const edgeMarkers = (edge, type) => ({ start: null, end: hasArrow(edge, type) ? 'arrow' : null, ...getDiagram(type).markers?.[edge.kind] });
@@ -0,0 +1,38 @@
1
+ import { translate } from '../i18n.js';
2
+ import { text, fit, TYPOGRAPHY, kindLabels, rectangle, actor, paint, escapeXml } from './drawing.js';
3
+ import { mix } from '../visual-style.js';
4
+
5
+ const outline = (node, x, y) => node.kind === 'actor' ? actor(node, x, y) : rectangle(node, x, y, 7, TYPOGRAPHY.sequenceHeader);
6
+ export const sequenceHeaderHeight = node => node.kind === 'actor' ? TYPOGRAPHY.sequenceActorHeader + (node.subtitle ? 22 : 0) : TYPOGRAPHY.sequenceHeader;
7
+
8
+ function sequenceNode(node, x, y, fill, stroke, palette, locale) {
9
+ const cx = x + node.size.width / 2;
10
+ const actorNode = node.kind === 'actor';
11
+ const header = sequenceHeaderHeight(node);
12
+ const head = paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.5 });
13
+ const label = actorNode
14
+ ? text(cx, y + 98, fit(node.label, node.size.width - 12), 'participant-title', ` text-anchor="middle" style="fill:${palette.ink}"`)
15
+ : text(cx, y + (node.subtitle ? 18 : 25), translate(locale, kindLabels[node.kind] ?? node.kind), 'stereotype', ' text-anchor="middle"') + text(cx, y + (node.subtitle ? 41 : 53), fit(node.label, node.size.width - 28), 'participant-title', ' text-anchor="middle"');
16
+ const subtitle = node.subtitle ? text(cx, y + (actorNode ? 120 : 63), fit(node.subtitle, node.size.width - 28), 'body', ' text-anchor="middle"') : '';
17
+ const executions = (node.executionRects ?? []).map(item => `<rect class="sequence-execution" data-execution-id="${escapeXml(item.id)}" x="${x + item.x}" y="${y + item.y}" width="${item.width}" height="${item.height}" fill="${mix(item.color, palette.surface, .14)}" stroke="${item.color}" stroke-width="1.5"/>`).join('');
18
+ return `<g><g class="sequence-head"><g class="${actorNode ? 'actor-figure' : 'participant-head'}">${head}</g>${label}${subtitle}</g><path class="lifeline" d="M ${cx} ${y + header}V ${y + node.size.height}" stroke="${palette.edge}" stroke-width="1" stroke-dasharray="4 6"/>${executions}</g>`;
19
+ }
20
+
21
+ export default {
22
+ dashedKinds: ['return'],
23
+ markers: { async: { end: 'arrow-open' }, return: { end: 'arrow-open' } },
24
+ id: 'sequence', label: 'Sequence',
25
+ nodeKinds: ["actor", "participant", "external", "service", "database"],
26
+ groupKinds: ["alt", "opt", "loop", "par"],
27
+ edgeKinds: ["sync", "async", "return"],
28
+ render: sequenceNode, outline,
29
+ sequence: true,
30
+ selectionHeight: sequenceHeaderHeight,
31
+ edgeLabel: edge => `${String(edge.order).padStart(2, '0')} · ${edge.label ?? ''}`,
32
+ validateEdge(edge, label, errors, { requireString, sequenceOrders }) {
33
+ if (!Number.isInteger(edge?.order) || edge.order < 1) errors.push(`${label}.order must be a positive integer for sequence`);
34
+ else if (sequenceOrders.has(edge.order)) errors.push(`${label}.order duplicates ${edge.order}`);
35
+ else sequenceOrders.add(edge.order);
36
+ requireString(edge?.label, `${label}.label`, errors);
37
+ },
38
+ };
@@ -0,0 +1,91 @@
1
+ import { centeredTitle, rectangle, diamond, paint, ellipseAnchor, polygonAnchor, rectAnchor, text, layoutText, TYPOGRAPHY } from './drawing.js';
2
+
3
+ export const stateSymbolX = node => node.subtitle ? 14 : node.size.width / 2;
4
+
5
+ // UML state actions: a state may list what it does on entry, while it is active (do) and on exit, below a divider.
6
+ export const STATE_ACTIONS = ['entry', 'do', 'exit'];
7
+ const ACTION = { side: 16, pad: 8, line: 20, keyWidth: 64 };
8
+ export function stateActivities(node) {
9
+ const rows = node.kind === 'state' ? STATE_ACTIONS.filter(key => typeof node[key] === 'string' && node[key].trim())
10
+ .map(key => ({ key, lines: layoutText(node[key], node.size.width - 2 * ACTION.side - ACTION.keyWidth, TYPOGRAPHY.small, ACTION.line).lines })) : [];
11
+ return { rows, height: rows.length ? rows.reduce((sum, row) => sum + row.lines.length, 0) * ACTION.line + 2 * ACTION.pad : 0 };
12
+ }
13
+ export const STATE_ACTION_INSET = 2 * ACTION.side + ACTION.keyWidth;
14
+ // The action list sits in the bottom compartment; the title keeps the compartment above it.
15
+ export const activityArea = node => {
16
+ const { height } = stateActivities(node);
17
+ return height ? { x: ACTION.side, y: node.size.height - height, width: node.size.width - 2 * ACTION.side, height } : null;
18
+ };
19
+
20
+ const outline = (node, x, y) => {
21
+ if (['initial', 'final'].includes(node.kind)) return [['circle', { cx: x + stateSymbolX(node), cy: y + node.size.height / 2, r: node.kind === 'initial' ? 12 : 13 }]];
22
+ return node.kind === 'choice' ? diamond(node, x, y) : rectangle(node, x, y, 12);
23
+ };
24
+ const anchor = (node, side, offset) => {
25
+ if (['initial', 'final'].includes(node.kind)) {
26
+ const radius = node.kind === 'initial' ? 12 : 13;
27
+ const shift = stateSymbolX(node) - node.size.width / 2;
28
+ const circle = { ...node, position: { ...node.position, x: node.position.x + shift } };
29
+ return ellipseAnchor(circle, side, offset - (['top', 'bottom'].includes(side) ? shift : 0), node.size.width / 2 - radius, node.size.height / 2 - radius);
30
+ }
31
+ return node.kind === 'choice' ? polygonAnchor(node, side, offset, [[.5, 0], [1, .5], [.5, 1], [0, .5]]) : rectAnchor(node, side, offset);
32
+ };
33
+ const textArea = node => {
34
+ if (['initial', 'final'].includes(node.kind) && node.subtitle) return { x: 42, y: 8, width: node.size.width - 50, height: node.size.height - 16 };
35
+ const actions = stateActivities(node).height;
36
+ const area = { width: node.kind === 'choice' ? node.size.width / 2 - 8 : node.size.width - 24, height: node.kind === 'choice' ? node.size.height / 2 - 8 : node.size.height - 16 - actions };
37
+ return actions ? { x: 12, y: 8, ...area } : area;
38
+ };
39
+
40
+ function activityMarkup(node, x, y, stroke, palette) {
41
+ const { rows, height } = stateActivities(node);
42
+ if (!rows.length) return '';
43
+ const top = y + node.size.height - height;
44
+ let index = 0;
45
+ return `<path d="M${x} ${top}H${x + node.size.width}" fill="none" stroke="${stroke}" stroke-opacity=".35"/>${rows.map(row => row.lines.map((line, i) => {
46
+ const baseline = top + ACTION.pad + index++ * ACTION.line + 15;
47
+ return (i ? '' : text(x + ACTION.side, baseline, `${row.key} /`, 'field-key'))
48
+ + text(x + ACTION.side + ACTION.keyWidth, baseline, line, 'entity-meta', ` style="fill:${palette.ink}"`);
49
+ }).join('')).join('')}`;
50
+ }
51
+
52
+ function stateNode(node, x, y, fill, stroke, palette) {
53
+ const cx = x + node.size.width / 2, cy = y + node.size.height / 2;
54
+ if (['initial', 'final'].includes(node.kind)) {
55
+ const area = textArea(node), final = node.kind === 'final';
56
+ return `<g class="state-dot">${paint(outline(node, x, y), { fill: final ? palette.surface : stroke, stroke, 'stroke-width': 1 })}${final ? `<circle cx="${x + stateSymbolX(node)}" cy="${cy}" r="8" fill="${stroke}"/>` : ''}</g>${node.subtitle ? centeredTitle(x + area.x + area.width / 2, cy, node.label, area.width, area.height, node.subtitle) : ''}`;
57
+ }
58
+ const area = textArea(node), titleY = y + (node.size.height - stateActivities(node).height) / 2;
59
+ return `<g class="shape-label">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.2 })}${centeredTitle(cx, titleY, node.label, area.width, area.height, node.subtitle)}${activityMarkup(node, x, y, stroke, palette)}</g>`;
60
+ }
61
+
62
+ // Trigger, [guard] and / effect are told apart by color; the label string is these parts joined by single spaces.
63
+ const labelParts = edge => [
64
+ { text: edge.label, role: 'trigger' },
65
+ ...(edge.guard ? [{ text: `[${edge.guard}]`, role: 'guard' }] : []),
66
+ ...(edge.action ? [{ text: `/ ${edge.action}`, role: 'effect' }] : [])
67
+ ].filter(part => part.text);
68
+
69
+ export default {
70
+ id: 'state', label: 'State diagram',
71
+ nodeKinds: ["initial", "state", "final", "choice"],
72
+ groupKinds: [],
73
+ edgeKinds: ["transition"],
74
+ render: stateNode, outline, anchor, textArea, activityArea,
75
+ curvedSelfLoops: true,
76
+ markers: { transition: { end: 'arrow-open' } },
77
+ edgeLabel: edge => labelParts(edge).map(part => part.text).join(' '),
78
+ edgeLabelParts: labelParts,
79
+ labelRoleColors: { trigger: 'ink', guard: 'guard' },
80
+ legend: { neutral: 'State', core: 'Core state', goal: 'Core state', ended: 'Ended state', failed: 'Failed state', flight: 'In-progress state: warm to cool as it advances', solid: 'Transition' },
81
+ validateNode(node, label, errors) {
82
+ for (const key of STATE_ACTIONS) {
83
+ if (node?.[key] === undefined) continue;
84
+ if (typeof node[key] !== 'string' || !node[key].trim()) errors.push(`${label}.${key} must be a non-empty string`);
85
+ else if (node.kind !== 'state') errors.push(`${label}.${key} is only supported on state nodes`);
86
+ }
87
+ },
88
+ validateEdge(edge, label, errors) {
89
+ for (const field of ['guard', 'action']) if (edge?.[field] !== undefined && typeof edge[field] !== 'string') errors.push(`${label}.${field} must be a string`);
90
+ },
91
+ };