@transitrix/diagrams 1.7.6 → 1.8.2

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 (64) hide show
  1. package/dist/__tests__/node-size-presets.test.d.ts +1 -0
  2. package/dist/__tests__/node-size-presets.test.js +30 -0
  3. package/dist/activities/layout.js +7 -7
  4. package/dist/activities/types.d.ts +4 -0
  5. package/dist/blocks/__tests__/layout.test.js +2 -2
  6. package/dist/blocks/layout.js +3 -2
  7. package/dist/capability-map/dsm-layout.js +1 -1
  8. package/dist/capability-map/dsm-schema.d.ts +1 -1
  9. package/dist/capability-map/dsm-schema.js +1 -1
  10. package/dist/capability-map/layout-tree.d.ts +6 -2
  11. package/dist/capability-map/layout-tree.js +12 -10
  12. package/dist/capability-map/render-capability-tree.d.ts +2 -9
  13. package/dist/capability-map/render-capability-tree.js +16 -28
  14. package/dist/fgca/preview-layout.d.ts +10 -6
  15. package/dist/fgca/preview-layout.js +16 -15
  16. package/dist/node-size-presets.d.ts +63 -0
  17. package/dist/node-size-presets.js +117 -0
  18. package/dist/process-blueprint/layout.js +4 -47
  19. package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
  20. package/dist/webview/__tests__/entity-text-layout.test.js +60 -0
  21. package/dist/webview/__tests__/entry.test.js +3 -3
  22. package/dist/webview/__tests__/render-blocks.test.js +10 -13
  23. package/dist/webview/__tests__/render-goals.test.js +41 -0
  24. package/dist/webview/entity-text-layout.d.ts +95 -0
  25. package/dist/webview/entity-text-layout.js +277 -0
  26. package/dist/webview/entry.js +7 -3
  27. package/dist/webview/notation-style.d.ts +1 -1
  28. package/dist/webview/notation-style.js +1 -1
  29. package/dist/webview/render-activities.d.ts +3 -1
  30. package/dist/webview/render-activities.js +29 -32
  31. package/dist/webview/render-blocks.d.ts +4 -1
  32. package/dist/webview/render-blocks.js +27 -108
  33. package/dist/webview/render-fgca.d.ts +15 -8
  34. package/dist/webview/render-fgca.js +41 -44
  35. package/dist/webview/render-goals.d.ts +2 -0
  36. package/dist/webview/render-goals.js +21 -36
  37. package/dist/webview/render-process-blueprint.d.ts +4 -10
  38. package/dist/webview/render-process-blueprint.js +18 -64
  39. package/package.json +1 -1
  40. package/src/__tests__/node-size-presets.test.ts +50 -0
  41. package/src/activities/layout.ts +12 -7
  42. package/src/activities/types.ts +4 -0
  43. package/src/blocks/__tests__/layout.test.ts +2 -2
  44. package/src/blocks/layout.ts +3 -2
  45. package/src/capability-map/dsm-layout.ts +1 -1
  46. package/src/capability-map/dsm-schema.ts +1 -1
  47. package/src/capability-map/index.ts +1 -1
  48. package/src/capability-map/layout-tree.ts +17 -9
  49. package/src/capability-map/render-capability-tree.ts +18 -30
  50. package/src/fgca/preview-layout.ts +24 -17
  51. package/src/node-size-presets.ts +180 -0
  52. package/src/process-blueprint/layout.ts +4 -48
  53. package/src/webview/__tests__/entity-text-layout.test.ts +73 -0
  54. package/src/webview/__tests__/entry.test.ts +3 -3
  55. package/src/webview/__tests__/render-blocks.test.ts +11 -14
  56. package/src/webview/__tests__/render-goals.test.ts +42 -0
  57. package/src/webview/entity-text-layout.ts +355 -0
  58. package/src/webview/entry.ts +8 -4
  59. package/src/webview/notation-style.ts +1 -1
  60. package/src/webview/render-activities.ts +38 -27
  61. package/src/webview/render-blocks.ts +35 -118
  62. package/src/webview/render-fgca.ts +59 -44
  63. package/src/webview/render-goals.ts +22 -34
  64. package/src/webview/render-process-blueprint.ts +30 -65
@@ -1,3 +1,4 @@
1
+ import { wrapTextLines } from '../webview/entity-text-layout.js';
1
2
  const ASPECT_CATEGORIES = ['systems', 'actors', 'equipment', 'information_entities'];
2
3
  const ASPECT_LABEL = {
3
4
  systems: 'Systems',
@@ -30,55 +31,11 @@ function resolveOptions(options) {
30
31
  return { ...DEFAULTS, ...(options ?? {}) };
31
32
  }
32
33
  /**
33
- * Greedy word-wrap to a max characters-per-line budget. Words longer than the
34
- * budget are hard-split; if the result exceeds `maxLines`, the last kept line
35
- * is truncated with an ellipsis so a single pathological cell can't grow the
36
- * whole row unbounded.
34
+ * Greedy word-wrap to a max characters-per-line budget. Delegates to the shared
35
+ * shared text-layout module.
37
36
  */
38
37
  function wrapText(text, maxChars, maxLines) {
39
- const trimmed = (text ?? '').trim();
40
- if (trimmed.length === 0)
41
- return [];
42
- const budget = Math.max(1, maxChars);
43
- const words = trimmed.split(/\s+/);
44
- const lines = [];
45
- let cur = '';
46
- const flush = () => {
47
- if (cur.length > 0) {
48
- lines.push(cur);
49
- cur = '';
50
- }
51
- };
52
- for (const word of words) {
53
- if (word.length > budget) {
54
- // Token wider than the cell: hard-break it across lines.
55
- flush();
56
- let rest = word;
57
- while (rest.length > budget) {
58
- lines.push(rest.slice(0, budget));
59
- rest = rest.slice(budget);
60
- }
61
- cur = rest;
62
- continue;
63
- }
64
- const candidate = cur.length > 0 ? `${cur} ${word}` : word;
65
- if (candidate.length > budget) {
66
- flush();
67
- cur = word;
68
- }
69
- else {
70
- cur = candidate;
71
- }
72
- }
73
- flush();
74
- if (lines.length > maxLines) {
75
- const capped = lines.slice(0, maxLines);
76
- const last = capped[maxLines - 1];
77
- const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
78
- capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
79
- return capped;
80
- }
81
- return lines;
38
+ return wrapTextLines(text, maxChars, maxLines);
82
39
  }
83
40
  function buildStageIndex(stages) {
84
41
  const idx = new Map();
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { layoutCenteredEntityText, LINE_H_PRIMARY, LINE_H_SECONDARY, maxCharsForInnerWidth, ROW_GROUP_GAP, wrapWords, wrapTextLines, } from '../entity-text-layout.js';
3
+ describe('entity-text-layout', () => {
4
+ it('wrapWords respects max lines and truncates with ellipsis', () => {
5
+ const long = 'one two three four five six seven eight nine ten eleven twelve';
6
+ const lines = wrapWords(long, 12, 2);
7
+ expect(lines.length).toBeLessThanOrEqual(2);
8
+ expect(lines[lines.length - 1]).toMatch(/…$/);
9
+ });
10
+ it('maxCharsForInnerWidth scales with inner width', () => {
11
+ expect(maxCharsForInnerWidth(160, 7)).toBeGreaterThan(maxCharsForInnerWidth(120, 7));
12
+ });
13
+ it('layoutCenteredEntityText keeps name and type labels separated', () => {
14
+ const specs = layoutCenteredEntityText({
15
+ boxX: 0,
16
+ boxY: 0,
17
+ boxWidth: 250,
18
+ boxHeight: 80,
19
+ name: 'Grow enterprise value',
20
+ type: 'Strategic',
21
+ id: 'GOAL-VALUE-1',
22
+ nameMaxLines: 2,
23
+ idMaxLines: 1,
24
+ });
25
+ const nameY = specs.find((l) => l.cls === 'text-primary').y;
26
+ const typeY = specs.find((l) => l.cls === 'text-secondary').y;
27
+ const minGap = typeY - nameY - LINE_H_PRIMARY / 2 - LINE_H_SECONDARY / 2;
28
+ expect(minGap).toBeGreaterThanOrEqual(ROW_GROUP_GAP - 1);
29
+ });
30
+ it('layoutCenteredEntityText emits more name capacity on wide boxes', () => {
31
+ const narrow = layoutCenteredEntityText({
32
+ boxX: 0,
33
+ boxY: 0,
34
+ boxWidth: 200,
35
+ boxHeight: 80,
36
+ name: 'Improve customer onboarding experience across regions',
37
+ id: 'GOAL-001',
38
+ nameMaxLines: 2,
39
+ idMaxLines: 1,
40
+ });
41
+ const wide = layoutCenteredEntityText({
42
+ boxX: 0,
43
+ boxY: 0,
44
+ boxWidth: 320,
45
+ boxHeight: 80,
46
+ name: 'Improve customer onboarding experience across regions',
47
+ id: 'GOAL-001',
48
+ nameMaxLines: 2,
49
+ idMaxLines: 1,
50
+ });
51
+ const narrowPrimary = narrow.filter((l) => l.cls === 'text-primary').map((l) => l.text).join(' ');
52
+ const widePrimary = wide.filter((l) => l.cls === 'text-primary').map((l) => l.text).join(' ');
53
+ expect(widePrimary.replace(/…/g, '').length).toBeGreaterThanOrEqual(narrowPrimary.replace(/…/g, '').length);
54
+ });
55
+ it('wrapTextLines hard-breaks oversized tokens (blueprint cells)', () => {
56
+ const lines = wrapTextLines('supercalifragilisticexpialidocious word', 10, 3);
57
+ expect(lines.length).toBeGreaterThan(1);
58
+ expect(lines.every((l) => l.length <= 10)).toBe(true);
59
+ });
60
+ });
@@ -40,9 +40,9 @@ describe('webview/entry — Step 2 host API', () => {
40
40
  });
41
41
  it('routes non-goals kinds to their validator after Step 4 (no NOTATION-NOT-WIRED)', () => {
42
42
  // Every supported kind is now wired to its validator + renderer. A
43
- // malformed fgca document must surface a structured validation error from
44
- // the fgca validator — never the Step-2/3 "not wired" placeholder.
45
- const r = render('fgca', 'changes: []\n');
43
+ // malformed dgca document must surface a structured validation error from
44
+ // the dgca validator — never the Step-2/3 "not wired" placeholder.
45
+ const r = render('dgca', 'changes: []\n');
46
46
  expect(r.status).toBe('error');
47
47
  expect(r.errors.length).toBeGreaterThan(0);
48
48
  expect(r.errors.every((e) => e.code !== 'NOTATION-NOT-WIRED')).toBe(true);
@@ -8,13 +8,10 @@
8
8
  */
9
9
  import { describe, expect, it } from 'vitest';
10
10
  import { renderBlocksSvg } from '../render-blocks.js';
11
- // Approximate character widths used by the renderer when sizing labels.
12
- // Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
13
- // can compute the same overflow bounds the renderer uses.
14
- const CHAR_W = 7;
15
- const CHAR_W_ID = 6;
16
- // Same horizontal margin the renderer reserves so text never abuts the rect.
17
- const TEXT_MARGIN_X = 8;
11
+ import { CHAR_W_PRIMARY as CHAR_W, CHAR_W_ID, TEXT_MARGIN_X } from '../entity-text-layout.js';
12
+ import { ENTITY_NODE_SIZE } from '../../node-size-presets.js';
13
+ const LEAF_W = ENTITY_NODE_SIZE.normal.width;
14
+ const LEAF_H = ENTITY_NODE_SIZE.normal.height;
18
15
  function leafDoc(name, id) {
19
16
  return {
20
17
  notation: 'blocks',
@@ -75,7 +72,7 @@ describe('renderBlocksSvg — leaf labels', () => {
75
72
  }
76
73
  });
77
74
  it('wraps a long ID to at most 2 lines and truncates with …', () => {
78
- const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE';
75
+ const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE_EXTRA_TAIL_TO_FORCE_TRUNCATION';
79
76
  const svg = renderBlocksSvg(leafDoc('Short name', longId));
80
77
  const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
81
78
  expect(idLines.length).toBeGreaterThan(0);
@@ -84,10 +81,10 @@ describe('renderBlocksSvg — leaf labels', () => {
84
81
  expect(idLines[1].text.endsWith('…')).toBe(true);
85
82
  }
86
83
  });
87
- it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
84
+ it(`keeps every label inside the ${LEAF_W}px leaf width (approximated by CHAR_W * length)`, () => {
88
85
  const svg = renderBlocksSvg(leafDoc('Personal data records that linger after consent withdrawal across many systems', 'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE'));
89
86
  const labels = extractLabels(svg);
90
- const inner = 160 - TEXT_MARGIN_X * 2;
87
+ const inner = LEAF_W - TEXT_MARGIN_X * 2;
91
88
  for (const l of labels) {
92
89
  const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
93
90
  expect(l.text.length * cw).toBeLessThanOrEqual(inner);
@@ -111,8 +108,8 @@ describe('renderBlocksSvg — container headers', () => {
111
108
  it('truncates a long container ID so it does not overflow the block width', () => {
112
109
  const svg = renderBlocksSvg(containerDoc('Short name', 'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW', 'CHILD', 'Child'));
113
110
  const labels = extractLabels(svg);
114
- // The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
115
- const containerW = 160 + 24;
111
+ // Container width derives from its single leaf child: leafW + 2×padding.
112
+ const containerW = LEAF_W + 24;
116
113
  const inner = containerW - TEXT_MARGIN_X * 2;
117
114
  for (const l of labels) {
118
115
  const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
@@ -127,7 +124,7 @@ describe('renderBlocksSvg — container headers', () => {
127
124
  const longName = 'Active systems that are erasure-reachable within the standard 30-day operational window';
128
125
  const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
129
126
  const labels = extractLabels(svg);
130
- const containerW = 160 + 24;
127
+ const containerW = LEAF_W + 24;
131
128
  const inner = containerW - TEXT_MARGIN_X * 2;
132
129
  const primaries = labels.filter((l) => l.cls === 'text-primary');
133
130
  for (const l of primaries) {
@@ -58,6 +58,47 @@ describe('renderGoalsSvg', () => {
58
58
  expect(svg).toContain('width="0"');
59
59
  expect(svg).toContain('height="0"');
60
60
  });
61
+ it('renders name, type, and id on separate lines without overlap', () => {
62
+ const tree = {
63
+ goal_types: [
64
+ { name: 'Strategic', level: 0 },
65
+ { name: 'Objective', level: 1 },
66
+ ],
67
+ goals: [
68
+ {
69
+ id: 1,
70
+ canonical_id: 'GOAL-VALUE-1',
71
+ name: 'Grow enterprise value',
72
+ type: 'Strategic',
73
+ level: 0,
74
+ parent_id: 0,
75
+ },
76
+ {
77
+ id: 2,
78
+ canonical_id: 'GOAL-COST-1',
79
+ name: 'Reduce run cost 20%',
80
+ type: 'Objective',
81
+ level: 1,
82
+ parent_id: 1,
83
+ },
84
+ ],
85
+ };
86
+ const svg = renderGoalsSvg(tree);
87
+ expect(svg).toContain('Grow enterprise value');
88
+ expect(svg).toContain('Strategic');
89
+ expect(svg).toContain('GOAL-VALUE-1');
90
+ const typeMatches = [...svg.matchAll(/class="text-secondary"[^>]*y="(\d+)"/g)];
91
+ const nameMatches = [...svg.matchAll(/class="text-primary"[^>]*y="(\d+)"/g)];
92
+ expect(typeMatches.length).toBeGreaterThan(0);
93
+ for (const type of typeMatches) {
94
+ const typeY = Number(type[1]);
95
+ const nearestName = nameMatches
96
+ .map((m) => Number(m[1]))
97
+ .filter((y) => y < typeY)
98
+ .sort((a, b) => b - a)[0];
99
+ expect(typeY - nearestName).toBeGreaterThanOrEqual(18);
100
+ }
101
+ });
61
102
  it('shows canonical goal ids in node labels when present', () => {
62
103
  const tree = {
63
104
  goal_types: [{ name: 'Strategy', level: 0 }],
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shared text-in-block layout for Transitrix diagram renderers.
3
+ *
4
+ * Consolidates wrapping, truncation, and vertical placement patterns previously
5
+ * duplicated across Blocks, Goals, DGCA/DGA, Activities, Process Blueprint, and
6
+ * Capability Map renderers.
7
+ */
8
+ export declare const TEXT_MARGIN_X = 8;
9
+ /** Approximate character width (px) at text-primary size (12px/600). */
10
+ export declare const CHAR_W_PRIMARY = 7;
11
+ /** Approximate character width (px) at text-id size (10px/600). */
12
+ export declare const CHAR_W_ID = 6;
13
+ /** Approximate character width (px) at text-secondary size. */
14
+ export declare const CHAR_W_SECONDARY = 7;
15
+ /** Vertical advance between name line centres (text-primary). */
16
+ export declare const LINE_H_PRIMARY = 14;
17
+ /** Vertical advance between secondary line centres. */
18
+ export declare const LINE_H_SECONDARY = 14;
19
+ /** Vertical advance between id line centres (text-id). */
20
+ export declare const LINE_H_ID = 12;
21
+ /** Gap between the name block and the id block in leaf-style layouts. */
22
+ export declare const NAME_ID_GAP = 14;
23
+ /** Gap between stacked row groups (name → type → id). */
24
+ export declare const ROW_GROUP_GAP = 9;
25
+ export declare function maxCharsForInnerWidth(innerWidth: number, charWidth: number): number;
26
+ /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
27
+ export declare function wrapWords(text: string, maxChars: number, maxLines: number): string[];
28
+ /**
29
+ * Greedy word-wrap with hard-break for tokens wider than the budget. When the
30
+ * result exceeds `maxLines`, the last kept line is truncated with an ellipsis.
31
+ * Used by Process Blueprint goal/result cells and aspect pill names.
32
+ */
33
+ export declare function wrapTextLines(text: string, maxChars: number, maxLines: number): string[];
34
+ /** Split an ID (no spaces) across lines, breaking at `_` or `-`. */
35
+ export declare function wrapId(id: string, maxChars: number, maxLines?: number): string[];
36
+ /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
37
+ export declare function truncateLine(text: string, maxChars: number): string;
38
+ /** Single-line ID truncation that prefers `_`/`-` separators before a hard cut. */
39
+ export declare function truncateId(id: string, maxChars: number): string;
40
+ export interface TextLineSpec {
41
+ cls: string;
42
+ text: string;
43
+ y: number;
44
+ /** When set, use this x; otherwise callers supply a shared anchor (e.g. box centre). */
45
+ x?: number;
46
+ }
47
+ export interface LayoutCenteredEntityOptions {
48
+ boxX: number;
49
+ boxY: number;
50
+ boxWidth: number;
51
+ boxHeight: number;
52
+ name: string;
53
+ type?: string;
54
+ id: string;
55
+ nameMaxLines?: number;
56
+ idMaxLines?: number;
57
+ marginX?: number;
58
+ }
59
+ /**
60
+ * Layout centred entity text (name → optional type → id) inside a fixed box.
61
+ * Returns absolute SVG y-positions (line centres) for each rendered row.
62
+ */
63
+ export declare function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): TextLineSpec[];
64
+ /** Emit centred `<text>` elements from {@link layoutCenteredEntityText} specs. */
65
+ export declare function emitCenteredTextSvg(specs: TextLineSpec[], centerX: number, esc: (s: string) => string): string;
66
+ export interface LayoutLeafBlockTextOptions {
67
+ boxX: number;
68
+ boxY: number;
69
+ boxWidth: number;
70
+ boxHeight: number;
71
+ name: string;
72
+ id: string;
73
+ nameMaxLines?: number;
74
+ marginX?: number;
75
+ }
76
+ /** Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred. */
77
+ export declare function layoutLeafBlockText(opts: LayoutLeafBlockTextOptions): TextLineSpec[];
78
+ export interface LayoutHeaderBlockTextOptions {
79
+ boxX: number;
80
+ boxY: number;
81
+ boxWidth: number;
82
+ headerHeight: number;
83
+ name: string;
84
+ id: string;
85
+ marginX?: number;
86
+ }
87
+ /** Container header: single-line name + id stacked in the header strip. */
88
+ export declare function layoutHeaderBlockText(opts: LayoutHeaderBlockTextOptions): TextLineSpec[];
89
+ /**
90
+ * Left-anchored multi-line cell text, vertically centred (Process Blueprint cells).
91
+ * Returns one spec per line with absolute x baked into caller via anchorX.
92
+ */
93
+ export declare function layoutLeftCellLines(lines: string[], anchorX: number, cellTop: number, cellHeight: number, lineHeight: number, cls: string): TextLineSpec[];
94
+ /** Centred pill text: name lines + optional id line below. */
95
+ export declare function layoutCenteredPillText(pillX: number, pillY: number, pillWidth: number, pillHeight: number, nameLines: string[], idLine: string | undefined, nameLineHeight?: number): TextLineSpec[];
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Shared text-in-block layout for Transitrix diagram renderers.
3
+ *
4
+ * Consolidates wrapping, truncation, and vertical placement patterns previously
5
+ * duplicated across Blocks, Goals, DGCA/DGA, Activities, Process Blueprint, and
6
+ * Capability Map renderers.
7
+ */
8
+ export const TEXT_MARGIN_X = 8;
9
+ /** Approximate character width (px) at text-primary size (12px/600). */
10
+ export const CHAR_W_PRIMARY = 7;
11
+ /** Approximate character width (px) at text-id size (10px/600). */
12
+ export const CHAR_W_ID = 6;
13
+ /** Approximate character width (px) at text-secondary size. */
14
+ export const CHAR_W_SECONDARY = 7;
15
+ /** Vertical advance between name line centres (text-primary). */
16
+ export const LINE_H_PRIMARY = 14;
17
+ /** Vertical advance between secondary line centres. */
18
+ export const LINE_H_SECONDARY = 14;
19
+ /** Vertical advance between id line centres (text-id). */
20
+ export const LINE_H_ID = 12;
21
+ /** Gap between the name block and the id block in leaf-style layouts. */
22
+ export const NAME_ID_GAP = 14;
23
+ /** Gap between stacked row groups (name → type → id). */
24
+ export const ROW_GROUP_GAP = 9;
25
+ export function maxCharsForInnerWidth(innerWidth, charWidth) {
26
+ return Math.max(4, Math.floor(Math.max(0, innerWidth) / charWidth));
27
+ }
28
+ /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
29
+ export function wrapWords(text, maxChars, maxLines) {
30
+ const safeMax = Math.max(2, maxChars);
31
+ const words = text.split(' ');
32
+ const lines = [];
33
+ let cur = '';
34
+ for (let i = 0; i < words.length; i++) {
35
+ const w = words[i];
36
+ const next = cur ? `${cur} ${w}` : w;
37
+ if (next.length <= safeMax) {
38
+ cur = next;
39
+ }
40
+ else {
41
+ if (cur)
42
+ lines.push(cur);
43
+ cur = '';
44
+ if (lines.length >= maxLines)
45
+ break;
46
+ if (lines.length === maxLines - 1) {
47
+ const rest = words.slice(i).join(' ');
48
+ lines.push(rest.length <= safeMax ? rest : `${rest.slice(0, safeMax - 1)}…`);
49
+ return lines;
50
+ }
51
+ cur = w.length <= safeMax ? w : `${w.slice(0, safeMax - 1)}…`;
52
+ }
53
+ }
54
+ if (cur && lines.length < maxLines)
55
+ lines.push(cur);
56
+ return lines.length ? lines : [`${text.slice(0, safeMax - 1)}…`];
57
+ }
58
+ /**
59
+ * Greedy word-wrap with hard-break for tokens wider than the budget. When the
60
+ * result exceeds `maxLines`, the last kept line is truncated with an ellipsis.
61
+ * Used by Process Blueprint goal/result cells and aspect pill names.
62
+ */
63
+ export function wrapTextLines(text, maxChars, maxLines) {
64
+ const trimmed = (text ?? '').trim();
65
+ if (trimmed.length === 0)
66
+ return [];
67
+ const budget = Math.max(1, maxChars);
68
+ const words = trimmed.split(/\s+/);
69
+ const lines = [];
70
+ let cur = '';
71
+ const flush = () => {
72
+ if (cur.length > 0) {
73
+ lines.push(cur);
74
+ cur = '';
75
+ }
76
+ };
77
+ for (const word of words) {
78
+ if (word.length > budget) {
79
+ flush();
80
+ let rest = word;
81
+ while (rest.length > budget) {
82
+ lines.push(rest.slice(0, budget));
83
+ rest = rest.slice(budget);
84
+ }
85
+ cur = rest;
86
+ continue;
87
+ }
88
+ const candidate = cur.length > 0 ? `${cur} ${word}` : word;
89
+ if (candidate.length > budget) {
90
+ flush();
91
+ cur = word;
92
+ }
93
+ else {
94
+ cur = candidate;
95
+ }
96
+ }
97
+ flush();
98
+ if (lines.length > maxLines) {
99
+ const capped = lines.slice(0, maxLines);
100
+ const last = capped[maxLines - 1];
101
+ const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
102
+ capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
103
+ return capped;
104
+ }
105
+ return lines;
106
+ }
107
+ /** Split an ID (no spaces) across lines, breaking at `_` or `-`. */
108
+ export function wrapId(id, maxChars, maxLines = 2) {
109
+ if (maxLines <= 1)
110
+ return [truncateId(id, maxChars)];
111
+ if (id.length <= maxChars)
112
+ return [id];
113
+ const seg = id.slice(0, maxChars);
114
+ const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
115
+ const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
116
+ const isSep = id[cut] === '_' || id[cut] === '-';
117
+ const line1 = id.slice(0, cut);
118
+ const rest = id.slice(cut + (isSep ? 1 : 0));
119
+ return [line1, rest.length <= maxChars ? rest : `${rest.slice(0, maxChars - 1)}…`];
120
+ }
121
+ /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
122
+ export function truncateLine(text, maxChars) {
123
+ const safeMax = Math.max(2, maxChars);
124
+ if (text.length <= safeMax)
125
+ return text;
126
+ const cut = text.slice(0, safeMax - 1);
127
+ const lastSpace = cut.lastIndexOf(' ');
128
+ return `${lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut}…`;
129
+ }
130
+ /** Single-line ID truncation that prefers `_`/`-` separators before a hard cut. */
131
+ export function truncateId(id, maxChars) {
132
+ const safeMax = Math.max(2, maxChars);
133
+ if (id.length <= safeMax)
134
+ return id;
135
+ const cut = id.slice(0, safeMax - 1);
136
+ const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
137
+ return `${sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut}…`;
138
+ }
139
+ function totalVerticalSpan(groups) {
140
+ let span = 0;
141
+ for (let i = 0; i < groups.length; i++) {
142
+ const g = groups[i];
143
+ span += g.lines.length * g.lineHeight;
144
+ if (i < groups.length - 1)
145
+ span += g.gapAfter;
146
+ }
147
+ return span;
148
+ }
149
+ /**
150
+ * Layout centred entity text (name → optional type → id) inside a fixed box.
151
+ * Returns absolute SVG y-positions (line centres) for each rendered row.
152
+ */
153
+ export function layoutCenteredEntityText(opts) {
154
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
155
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
156
+ const cx = opts.boxX + opts.boxWidth / 2;
157
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
158
+ const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
159
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
160
+ const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 2);
161
+ const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
162
+ const idMaxLines = opts.idMaxLines ?? 1;
163
+ const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
164
+ const groups = [
165
+ {
166
+ cls: 'text-primary',
167
+ lines: nameLines,
168
+ lineHeight: LINE_H_PRIMARY,
169
+ gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
170
+ },
171
+ ];
172
+ if (typeLine) {
173
+ groups.push({
174
+ cls: 'text-secondary',
175
+ lines: [typeLine],
176
+ lineHeight: LINE_H_SECONDARY,
177
+ gapAfter: NAME_ID_GAP,
178
+ });
179
+ }
180
+ groups.push({
181
+ cls: 'text-id',
182
+ lines: idLines,
183
+ lineHeight: LINE_H_ID,
184
+ gapAfter: 0,
185
+ });
186
+ const span = totalVerticalSpan(groups);
187
+ const firstY = Math.round(opts.boxY + (opts.boxHeight - span) / 2);
188
+ const out = [];
189
+ let y = firstY;
190
+ for (let gi = 0; gi < groups.length; gi++) {
191
+ const g = groups[gi];
192
+ for (let li = 0; li < g.lines.length; li++) {
193
+ out.push({ cls: g.cls, text: g.lines[li], y });
194
+ y += g.lineHeight;
195
+ }
196
+ if (gi < groups.length - 1)
197
+ y += g.gapAfter;
198
+ }
199
+ return out;
200
+ }
201
+ /** Emit centred `<text>` elements from {@link layoutCenteredEntityText} specs. */
202
+ export function emitCenteredTextSvg(specs, centerX, esc) {
203
+ return specs
204
+ .map((spec) => {
205
+ const x = spec.x ?? centerX;
206
+ const anchor = spec.x != null && spec.cls === 'text-pill' ? ' text-anchor="middle"' : ' text-anchor="middle"';
207
+ return `<text class="${spec.cls}" x="${x}" y="${spec.y}"${anchor} dominant-baseline="central">${esc(spec.text)}</text>`;
208
+ })
209
+ .join('\n');
210
+ }
211
+ /** Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred. */
212
+ export function layoutLeafBlockText(opts) {
213
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
214
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
215
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
216
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
217
+ const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 3);
218
+ const idLines = wrapId(opts.id, maxCharsId, 2);
219
+ const nameSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
220
+ const idSpan = (idLines.length - 1) * LINE_H_ID;
221
+ const totalSpan = nameSpan + NAME_ID_GAP + idSpan;
222
+ const firstY = Math.round(opts.boxY + (opts.boxHeight - totalSpan) / 2);
223
+ const out = [];
224
+ for (let i = 0; i < nameLines.length; i++) {
225
+ out.push({ cls: 'text-primary', text: nameLines[i], y: firstY + i * LINE_H_PRIMARY });
226
+ }
227
+ const idFirstY = firstY + nameSpan + NAME_ID_GAP;
228
+ for (let i = 0; i < idLines.length; i++) {
229
+ out.push({ cls: 'text-id', text: idLines[i], y: idFirstY + i * LINE_H_ID });
230
+ }
231
+ return out;
232
+ }
233
+ /** Container header: single-line name + id stacked in the header strip. */
234
+ export function layoutHeaderBlockText(opts) {
235
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
236
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
237
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
238
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
239
+ const nameText = truncateLine(opts.name, maxCharsName);
240
+ const idText = truncateId(opts.id, maxCharsId);
241
+ const headerMid = opts.boxY + opts.headerHeight / 2;
242
+ const halfSpan = (LINE_H_PRIMARY + LINE_H_ID) / 4;
243
+ return [
244
+ { cls: 'text-primary', text: nameText, y: Math.round(headerMid - halfSpan) },
245
+ { cls: 'text-id', text: idText, y: Math.round(headerMid + halfSpan) },
246
+ ];
247
+ }
248
+ /**
249
+ * Left-anchored multi-line cell text, vertically centred (Process Blueprint cells).
250
+ * Returns one spec per line with absolute x baked into caller via anchorX.
251
+ */
252
+ export function layoutLeftCellLines(lines, anchorX, cellTop, cellHeight, lineHeight, cls) {
253
+ const ls = lines.length > 0 ? lines : [''];
254
+ const firstY = cellTop + cellHeight / 2 - ((ls.length - 1) / 2) * lineHeight;
255
+ return ls.map((text, i) => ({ cls, text, y: firstY + i * lineHeight, x: anchorX }));
256
+ }
257
+ /** Centred pill text: name lines + optional id line below. */
258
+ export function layoutCenteredPillText(pillX, pillY, pillWidth, pillHeight, nameLines, idLine, nameLineHeight = 15) {
259
+ const cx = pillX + pillWidth / 2;
260
+ const lineCount = nameLines.length + (idLine ? 1 : 0);
261
+ const firstY = pillY + pillHeight / 2 - ((lineCount - 1) / 2) * nameLineHeight;
262
+ const out = nameLines.map((text, i) => ({
263
+ cls: 'text-pill',
264
+ text,
265
+ y: firstY + i * nameLineHeight,
266
+ x: cx,
267
+ }));
268
+ if (idLine) {
269
+ out.push({
270
+ cls: 'text-secondary',
271
+ text: idLine,
272
+ y: firstY + nameLines.length * nameLineHeight,
273
+ x: cx,
274
+ });
275
+ }
276
+ return out;
277
+ }
@@ -19,13 +19,17 @@
19
19
  * (SVG for the diagram notations, an HTML fragment for the catalogue
20
20
  * notations). The JVM host drops whatever `svg` carries into the DOM, so the
21
21
  * field name is historical — it transports any self-contained markup.
22
+ *
23
+ * Import validators/types from leaf modules (e.g. `capability-map/validate.js`),
24
+ * never from package `index.ts` barrels that also re-export React views —
25
+ * esbuild cannot tree-shake those and the JCEF bundle would pull in React.
22
26
  */
23
27
  import yaml from 'js-yaml';
24
28
  import { validateActivities } from '../activities/index.js';
25
29
  import { validateActivityCard } from '../activity-card/index.js';
26
30
  import { validateApplicationsCatalogue } from '../applications/index.js';
27
31
  import { validateNestedBlocks } from '../blocks/index.js';
28
- import { validateCapabilityMap } from '../capability-map/index.js';
32
+ import { validateCapabilityMap } from '../capability-map/validate.js';
29
33
  import { parseCanonicalFGCA, parseCanonicalFGA } from '../fgca/parse-canonical.js';
30
34
  import { parseCanonicalGoals } from '../goals/parse-canonical.js';
31
35
  import { validateProcessBlueprint } from '../process-blueprint/index.js';
@@ -114,7 +118,7 @@ function dispatchValidate(kind, doc) {
114
118
  r.errors.push(...v.errors);
115
119
  r.warnings.push(...v.warnings);
116
120
  if (v.valid && v.parsed) {
117
- r.svg = renderFgcaSvg(v.parsed, { variant: 'fgca' });
121
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
118
122
  }
119
123
  return r;
120
124
  }
@@ -124,7 +128,7 @@ function dispatchValidate(kind, doc) {
124
128
  r.errors.push(...v.errors);
125
129
  r.warnings.push(...v.warnings);
126
130
  if (v.valid && v.parsed) {
127
- r.svg = renderFgcaSvg(v.parsed, { variant: 'fga' });
131
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
128
132
  }
129
133
  return r;
130
134
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared visual constants for entity-box (block-style) notation renderers.
3
3
  *
4
- * Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
4
+ * Goals, Nested Blocks, DGCA/DGA, and any future notation using the same
5
5
  * entity-box visual language import their shared style tokens from here.
6
6
  * Centralising them prevents the per-renderer drift that caused the Goals/Blocks
7
7
  * style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere