@transitrix/diagrams 1.7.6 → 1.8.1
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.
- package/dist/activities/layout.js +7 -7
- package/dist/activities/types.d.ts +4 -0
- package/dist/capability-map/dsm-schema.d.ts +1 -1
- package/dist/capability-map/dsm-schema.js +1 -1
- package/dist/capability-map/layout-tree.d.ts +5 -1
- package/dist/capability-map/layout-tree.js +11 -9
- package/dist/capability-map/render-capability-tree.d.ts +2 -9
- package/dist/capability-map/render-capability-tree.js +16 -28
- package/dist/fgca/preview-layout.d.ts +8 -4
- package/dist/fgca/preview-layout.js +13 -13
- package/dist/node-size-presets.d.ts +50 -0
- package/dist/node-size-presets.js +103 -0
- package/dist/process-blueprint/layout.js +4 -47
- package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
- package/dist/webview/__tests__/entity-text-layout.test.js +43 -0
- package/dist/webview/__tests__/entry.test.js +3 -3
- package/dist/webview/__tests__/render-blocks.test.js +1 -7
- package/dist/webview/entity-text-layout.d.ts +95 -0
- package/dist/webview/entity-text-layout.js +279 -0
- package/dist/webview/entry.js +7 -3
- package/dist/webview/notation-style.d.ts +1 -1
- package/dist/webview/notation-style.js +1 -1
- package/dist/webview/render-activities.d.ts +3 -1
- package/dist/webview/render-activities.js +29 -32
- package/dist/webview/render-blocks.d.ts +4 -1
- package/dist/webview/render-blocks.js +27 -108
- package/dist/webview/render-fgca.d.ts +15 -8
- package/dist/webview/render-fgca.js +41 -44
- package/dist/webview/render-goals.d.ts +2 -0
- package/dist/webview/render-goals.js +21 -36
- package/dist/webview/render-process-blueprint.d.ts +4 -10
- package/dist/webview/render-process-blueprint.js +18 -64
- package/package.json +1 -1
- package/src/activities/layout.ts +12 -7
- package/src/activities/types.ts +4 -0
- package/src/capability-map/dsm-schema.ts +1 -1
- package/src/capability-map/index.ts +1 -1
- package/src/capability-map/layout-tree.ts +16 -8
- package/src/capability-map/render-capability-tree.ts +18 -30
- package/src/fgca/preview-layout.ts +21 -15
- package/src/node-size-presets.ts +157 -0
- package/src/process-blueprint/layout.ts +4 -48
- package/src/webview/__tests__/entity-text-layout.test.ts +52 -0
- package/src/webview/__tests__/entry.test.ts +3 -3
- package/src/webview/__tests__/render-blocks.test.ts +1 -9
- package/src/webview/entity-text-layout.ts +355 -0
- package/src/webview/entry.ts +8 -4
- package/src/webview/notation-style.ts +1 -1
- package/src/webview/render-activities.ts +38 -27
- package/src/webview/render-blocks.ts +35 -118
- package/src/webview/render-fgca.ts +59 -44
- package/src/webview/render-goals.ts +22 -34
- package/src/webview/render-process-blueprint.ts +30 -65
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block/cell size presets for diagram renderers.
|
|
3
|
+
*
|
|
4
|
+
* Presets drive both layout geometry and shared text-layout char budgets.
|
|
5
|
+
* Smooth per-pixel sliders are intentionally deferred.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type NodeSizePreset = 'compact' | 'normal' | 'wide';
|
|
9
|
+
|
|
10
|
+
export const NODE_SIZE_PRESET_VALUES: readonly NodeSizePreset[] = ['compact', 'normal', 'wide'] as const;
|
|
11
|
+
|
|
12
|
+
export interface BoxDimensions {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BlocksLeafDimensions extends BoxDimensions {}
|
|
18
|
+
|
|
19
|
+
export interface ProcessBlueprintDimensions {
|
|
20
|
+
legendColumnWidth: number;
|
|
21
|
+
stageColumnWidth: number;
|
|
22
|
+
stageHeaderHeight: number;
|
|
23
|
+
goalRowHeight: number;
|
|
24
|
+
resultRowHeight: number;
|
|
25
|
+
aspectRowMinHeight: number;
|
|
26
|
+
pillHeight: number;
|
|
27
|
+
pillGap: number;
|
|
28
|
+
cellPadding: number;
|
|
29
|
+
textLineHeight: number;
|
|
30
|
+
textCharWidth: number;
|
|
31
|
+
cellTextPadX: number;
|
|
32
|
+
cellTextPadY: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CapabilityMapDimensions {
|
|
36
|
+
nodeWidth: number;
|
|
37
|
+
nodeHeight: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type PresetTable<T> = Record<NodeSizePreset, T>;
|
|
41
|
+
|
|
42
|
+
export const GOALS_NODE_SIZE: PresetTable<BoxDimensions> = {
|
|
43
|
+
compact: { width: 200, height: 72 },
|
|
44
|
+
normal: { width: 250, height: 72 },
|
|
45
|
+
wide: { width: 320, height: 72 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const DGCA_NODE_SIZE: PresetTable<BoxDimensions> = {
|
|
49
|
+
compact: { width: 200, height: 72 },
|
|
50
|
+
normal: { width: 220, height: 72 },
|
|
51
|
+
wide: { width: 280, height: 72 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const BLOCKS_LEAF_SIZE: PresetTable<BlocksLeafDimensions> = {
|
|
55
|
+
compact: { width: 140, height: 72 },
|
|
56
|
+
normal: { width: 160, height: 72 },
|
|
57
|
+
wide: { width: 200, height: 72 },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const ACTION_NODE_SIZE: PresetTable<BoxDimensions> = {
|
|
61
|
+
compact: { width: 180, height: 72 },
|
|
62
|
+
normal: { width: 200, height: 80 },
|
|
63
|
+
wide: { width: 260, height: 80 },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const PROCESS_BLUEPRINT_SIZE: PresetTable<ProcessBlueprintDimensions> = {
|
|
67
|
+
compact: {
|
|
68
|
+
legendColumnWidth: 120,
|
|
69
|
+
stageColumnWidth: 180,
|
|
70
|
+
stageHeaderHeight: 36,
|
|
71
|
+
goalRowHeight: 52,
|
|
72
|
+
resultRowHeight: 52,
|
|
73
|
+
aspectRowMinHeight: 56,
|
|
74
|
+
pillHeight: 36,
|
|
75
|
+
pillGap: 4,
|
|
76
|
+
cellPadding: 8,
|
|
77
|
+
textLineHeight: 16,
|
|
78
|
+
textCharWidth: 7.5,
|
|
79
|
+
cellTextPadX: 10,
|
|
80
|
+
cellTextPadY: 10,
|
|
81
|
+
},
|
|
82
|
+
normal: {
|
|
83
|
+
legendColumnWidth: 140,
|
|
84
|
+
stageColumnWidth: 220,
|
|
85
|
+
stageHeaderHeight: 40,
|
|
86
|
+
goalRowHeight: 56,
|
|
87
|
+
resultRowHeight: 56,
|
|
88
|
+
aspectRowMinHeight: 60,
|
|
89
|
+
pillHeight: 40,
|
|
90
|
+
pillGap: 4,
|
|
91
|
+
cellPadding: 8,
|
|
92
|
+
textLineHeight: 17,
|
|
93
|
+
textCharWidth: 7.5,
|
|
94
|
+
cellTextPadX: 10,
|
|
95
|
+
cellTextPadY: 10,
|
|
96
|
+
},
|
|
97
|
+
wide: {
|
|
98
|
+
legendColumnWidth: 160,
|
|
99
|
+
stageColumnWidth: 280,
|
|
100
|
+
stageHeaderHeight: 44,
|
|
101
|
+
goalRowHeight: 60,
|
|
102
|
+
resultRowHeight: 60,
|
|
103
|
+
aspectRowMinHeight: 64,
|
|
104
|
+
pillHeight: 44,
|
|
105
|
+
pillGap: 4,
|
|
106
|
+
cellPadding: 8,
|
|
107
|
+
textLineHeight: 18,
|
|
108
|
+
textCharWidth: 7.5,
|
|
109
|
+
cellTextPadX: 10,
|
|
110
|
+
cellTextPadY: 10,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const CAPABILITY_MAP_NODE_SIZE: PresetTable<CapabilityMapDimensions> = {
|
|
115
|
+
compact: { nodeWidth: 200, nodeHeight: 56 },
|
|
116
|
+
normal: { nodeWidth: 240, nodeHeight: 60 },
|
|
117
|
+
wide: { nodeWidth: 300, nodeHeight: 64 },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type NodeSizeNotation =
|
|
121
|
+
| 'goals'
|
|
122
|
+
| 'dgca'
|
|
123
|
+
| 'dga'
|
|
124
|
+
| 'blocks'
|
|
125
|
+
| 'action'
|
|
126
|
+
| 'processBlueprint'
|
|
127
|
+
| 'capabilityMap';
|
|
128
|
+
|
|
129
|
+
/** Parse a VS Code setting value into a preset id (defaults to `normal`). */
|
|
130
|
+
export function parseNodeSizePreset(value: string | undefined): NodeSizePreset {
|
|
131
|
+
if (value === 'compact' || value === 'wide') return value;
|
|
132
|
+
return 'normal';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveGoalsNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
136
|
+
return GOALS_NODE_SIZE[preset];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveDgcaNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
140
|
+
return DGCA_NODE_SIZE[preset];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveBlocksLeafSize(preset: NodeSizePreset): BlocksLeafDimensions {
|
|
144
|
+
return BLOCKS_LEAF_SIZE[preset];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveActionNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
148
|
+
return ACTION_NODE_SIZE[preset];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resolveProcessBlueprintSize(preset: NodeSizePreset): ProcessBlueprintDimensions {
|
|
152
|
+
return PROCESS_BLUEPRINT_SIZE[preset];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolveCapabilityMapNodeSize(preset: NodeSizePreset): CapabilityMapDimensions {
|
|
156
|
+
return CAPABILITY_MAP_NODE_SIZE[preset];
|
|
157
|
+
}
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
StageHeaderCell,
|
|
19
19
|
StageTextCell,
|
|
20
20
|
} from './types.js';
|
|
21
|
+
import { wrapTextLines } from '../webview/entity-text-layout.js';
|
|
21
22
|
|
|
22
23
|
const ASPECT_CATEGORIES: AspectCategory[] = ['systems', 'actors', 'equipment', 'information_entities'];
|
|
23
24
|
|
|
@@ -78,56 +79,11 @@ function resolveOptions(options: ProcessBlueprintLayoutOptions | undefined): Res
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
* Greedy word-wrap to a max characters-per-line budget.
|
|
82
|
-
*
|
|
83
|
-
* is truncated with an ellipsis so a single pathological cell can't grow the
|
|
84
|
-
* whole row unbounded.
|
|
82
|
+
* Greedy word-wrap to a max characters-per-line budget. Delegates to the shared
|
|
83
|
+
* shared text-layout module.
|
|
85
84
|
*/
|
|
86
85
|
function wrapText(text: string, maxChars: number, maxLines: number): string[] {
|
|
87
|
-
|
|
88
|
-
if (trimmed.length === 0) return [];
|
|
89
|
-
const budget = Math.max(1, maxChars);
|
|
90
|
-
|
|
91
|
-
const words = trimmed.split(/\s+/);
|
|
92
|
-
const lines: string[] = [];
|
|
93
|
-
let cur = '';
|
|
94
|
-
const flush = (): void => {
|
|
95
|
-
if (cur.length > 0) {
|
|
96
|
-
lines.push(cur);
|
|
97
|
-
cur = '';
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
for (const word of words) {
|
|
102
|
-
if (word.length > budget) {
|
|
103
|
-
// Token wider than the cell: hard-break it across lines.
|
|
104
|
-
flush();
|
|
105
|
-
let rest = word;
|
|
106
|
-
while (rest.length > budget) {
|
|
107
|
-
lines.push(rest.slice(0, budget));
|
|
108
|
-
rest = rest.slice(budget);
|
|
109
|
-
}
|
|
110
|
-
cur = rest;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const candidate = cur.length > 0 ? `${cur} ${word}` : word;
|
|
114
|
-
if (candidate.length > budget) {
|
|
115
|
-
flush();
|
|
116
|
-
cur = word;
|
|
117
|
-
} else {
|
|
118
|
-
cur = candidate;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
flush();
|
|
122
|
-
|
|
123
|
-
if (lines.length > maxLines) {
|
|
124
|
-
const capped = lines.slice(0, maxLines);
|
|
125
|
-
const last = capped[maxLines - 1];
|
|
126
|
-
const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
|
|
127
|
-
capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
|
|
128
|
-
return capped;
|
|
129
|
-
}
|
|
130
|
-
return lines;
|
|
86
|
+
return wrapTextLines(text, maxChars, maxLines);
|
|
131
87
|
}
|
|
132
88
|
|
|
133
89
|
function buildStageIndex(stages: Stage[]): Map<string, number> {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
layoutCenteredEntityText,
|
|
4
|
+
maxCharsForInnerWidth,
|
|
5
|
+
wrapWords,
|
|
6
|
+
wrapTextLines,
|
|
7
|
+
} from '../entity-text-layout.js';
|
|
8
|
+
|
|
9
|
+
describe('entity-text-layout', () => {
|
|
10
|
+
it('wrapWords respects max lines and truncates with ellipsis', () => {
|
|
11
|
+
const long = 'one two three four five six seven eight nine ten eleven twelve';
|
|
12
|
+
const lines = wrapWords(long, 12, 2);
|
|
13
|
+
expect(lines.length).toBeLessThanOrEqual(2);
|
|
14
|
+
expect(lines[lines.length - 1]).toMatch(/…$/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('maxCharsForInnerWidth scales with inner width', () => {
|
|
18
|
+
expect(maxCharsForInnerWidth(160, 7)).toBeGreaterThan(maxCharsForInnerWidth(120, 7));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('layoutCenteredEntityText emits more name capacity on wide boxes', () => {
|
|
22
|
+
const narrow = layoutCenteredEntityText({
|
|
23
|
+
boxX: 0,
|
|
24
|
+
boxY: 0,
|
|
25
|
+
boxWidth: 200,
|
|
26
|
+
boxHeight: 72,
|
|
27
|
+
name: 'Improve customer onboarding experience across regions',
|
|
28
|
+
id: 'GOAL-001',
|
|
29
|
+
nameMaxLines: 2,
|
|
30
|
+
idMaxLines: 1,
|
|
31
|
+
});
|
|
32
|
+
const wide = layoutCenteredEntityText({
|
|
33
|
+
boxX: 0,
|
|
34
|
+
boxY: 0,
|
|
35
|
+
boxWidth: 320,
|
|
36
|
+
boxHeight: 72,
|
|
37
|
+
name: 'Improve customer onboarding experience across regions',
|
|
38
|
+
id: 'GOAL-001',
|
|
39
|
+
nameMaxLines: 2,
|
|
40
|
+
idMaxLines: 1,
|
|
41
|
+
});
|
|
42
|
+
const narrowPrimary = narrow.filter((l) => l.cls === 'text-primary').map((l) => l.text).join(' ');
|
|
43
|
+
const widePrimary = wide.filter((l) => l.cls === 'text-primary').map((l) => l.text).join(' ');
|
|
44
|
+
expect(widePrimary.replace(/…/g, '').length).toBeGreaterThanOrEqual(narrowPrimary.replace(/…/g, '').length);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('wrapTextLines hard-breaks oversized tokens (blueprint cells)', () => {
|
|
48
|
+
const lines = wrapTextLines('supercalifragilisticexpialidocious word', 10, 3);
|
|
49
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
50
|
+
expect(lines.every((l) => l.length <= 10)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -47,9 +47,9 @@ describe('webview/entry — Step 2 host API', () => {
|
|
|
47
47
|
|
|
48
48
|
it('routes non-goals kinds to their validator after Step 4 (no NOTATION-NOT-WIRED)', () => {
|
|
49
49
|
// Every supported kind is now wired to its validator + renderer. A
|
|
50
|
-
// malformed
|
|
51
|
-
// the
|
|
52
|
-
const r = render('
|
|
50
|
+
// malformed dgca document must surface a structured validation error from
|
|
51
|
+
// the dgca validator — never the Step-2/3 "not wired" placeholder.
|
|
52
|
+
const r = render('dgca', 'changes: []\n');
|
|
53
53
|
expect(r.status).toBe('error');
|
|
54
54
|
expect(r.errors.length).toBeGreaterThan(0);
|
|
55
55
|
expect(r.errors.every((e) => e.code !== 'NOTATION-NOT-WIRED')).toBe(true);
|
|
@@ -10,15 +10,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
10
10
|
|
|
11
11
|
import type { BlocksFile } from '../../blocks/types.js';
|
|
12
12
|
import { renderBlocksSvg } from '../render-blocks.js';
|
|
13
|
-
|
|
14
|
-
// Approximate character widths used by the renderer when sizing labels.
|
|
15
|
-
// Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
|
|
16
|
-
// can compute the same overflow bounds the renderer uses.
|
|
17
|
-
const CHAR_W = 7;
|
|
18
|
-
const CHAR_W_ID = 6;
|
|
19
|
-
|
|
20
|
-
// Same horizontal margin the renderer reserves so text never abuts the rect.
|
|
21
|
-
const TEXT_MARGIN_X = 8;
|
|
13
|
+
import { CHAR_W_PRIMARY as CHAR_W, CHAR_W_ID, TEXT_MARGIN_X } from '../entity-text-layout.js';
|
|
22
14
|
|
|
23
15
|
function leafDoc(name: string, id: string): BlocksFile {
|
|
24
16
|
return {
|
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
|
|
9
|
+
export const TEXT_MARGIN_X = 8;
|
|
10
|
+
/** Approximate character width (px) at text-primary size (12px/600). */
|
|
11
|
+
export const CHAR_W_PRIMARY = 7;
|
|
12
|
+
/** Approximate character width (px) at text-id size (10px/600). */
|
|
13
|
+
export const CHAR_W_ID = 6;
|
|
14
|
+
/** Approximate character width (px) at text-secondary size. */
|
|
15
|
+
export const CHAR_W_SECONDARY = 7;
|
|
16
|
+
/** Vertical advance between name line centres (text-primary). */
|
|
17
|
+
export const LINE_H_PRIMARY = 14;
|
|
18
|
+
/** Vertical advance between secondary line centres. */
|
|
19
|
+
export const LINE_H_SECONDARY = 14;
|
|
20
|
+
/** Vertical advance between id line centres (text-id). */
|
|
21
|
+
export const LINE_H_ID = 12;
|
|
22
|
+
/** Gap between the name block and the id block in leaf-style layouts. */
|
|
23
|
+
export const NAME_ID_GAP = 14;
|
|
24
|
+
/** Gap between stacked row groups (name → type → id). */
|
|
25
|
+
export const ROW_GROUP_GAP = 9;
|
|
26
|
+
|
|
27
|
+
export function maxCharsForInnerWidth(innerWidth: number, charWidth: number): number {
|
|
28
|
+
return Math.max(4, Math.floor(Math.max(0, innerWidth) / charWidth));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
|
|
32
|
+
export function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
|
|
33
|
+
const safeMax = Math.max(2, maxChars);
|
|
34
|
+
const words = text.split(' ');
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
let cur = '';
|
|
37
|
+
for (let i = 0; i < words.length; i++) {
|
|
38
|
+
const w = words[i];
|
|
39
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
40
|
+
if (next.length <= safeMax) {
|
|
41
|
+
cur = next;
|
|
42
|
+
} else {
|
|
43
|
+
if (cur) lines.push(cur);
|
|
44
|
+
cur = '';
|
|
45
|
+
if (lines.length >= maxLines) 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) lines.push(cur);
|
|
55
|
+
return lines.length ? lines : [`${text.slice(0, safeMax - 1)}…`];
|
|
56
|
+
}
|
|
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: string, maxChars: number, maxLines: number): string[] {
|
|
64
|
+
const trimmed = (text ?? '').trim();
|
|
65
|
+
if (trimmed.length === 0) return [];
|
|
66
|
+
const budget = Math.max(1, maxChars);
|
|
67
|
+
|
|
68
|
+
const words = trimmed.split(/\s+/);
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
let cur = '';
|
|
71
|
+
const flush = (): void => {
|
|
72
|
+
if (cur.length > 0) {
|
|
73
|
+
lines.push(cur);
|
|
74
|
+
cur = '';
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (const word of words) {
|
|
79
|
+
if (word.length > budget) {
|
|
80
|
+
flush();
|
|
81
|
+
let rest = word;
|
|
82
|
+
while (rest.length > budget) {
|
|
83
|
+
lines.push(rest.slice(0, budget));
|
|
84
|
+
rest = rest.slice(budget);
|
|
85
|
+
}
|
|
86
|
+
cur = rest;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const candidate = cur.length > 0 ? `${cur} ${word}` : word;
|
|
90
|
+
if (candidate.length > budget) {
|
|
91
|
+
flush();
|
|
92
|
+
cur = word;
|
|
93
|
+
} else {
|
|
94
|
+
cur = candidate;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
|
|
99
|
+
if (lines.length > maxLines) {
|
|
100
|
+
const capped = lines.slice(0, maxLines);
|
|
101
|
+
const last = capped[maxLines - 1];
|
|
102
|
+
const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
|
|
103
|
+
capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
|
|
104
|
+
return capped;
|
|
105
|
+
}
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Split an ID (no spaces) across lines, breaking at `_` or `-`. */
|
|
110
|
+
export function wrapId(id: string, maxChars: number, maxLines = 2): string[] {
|
|
111
|
+
if (maxLines <= 1) return [truncateId(id, maxChars)];
|
|
112
|
+
if (id.length <= maxChars) 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
|
+
|
|
122
|
+
/** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
|
|
123
|
+
export function truncateLine(text: string, maxChars: number): string {
|
|
124
|
+
const safeMax = Math.max(2, maxChars);
|
|
125
|
+
if (text.length <= safeMax) 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
|
+
|
|
131
|
+
/** Single-line ID truncation that prefers `_`/`-` separators before a hard cut. */
|
|
132
|
+
export function truncateId(id: string, maxChars: number): string {
|
|
133
|
+
const safeMax = Math.max(2, maxChars);
|
|
134
|
+
if (id.length <= safeMax) 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
|
+
|
|
140
|
+
export interface TextLineSpec {
|
|
141
|
+
cls: string;
|
|
142
|
+
text: string;
|
|
143
|
+
y: number;
|
|
144
|
+
/** When set, use this x; otherwise callers supply a shared anchor (e.g. box centre). */
|
|
145
|
+
x?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface LayoutCenteredEntityOptions {
|
|
149
|
+
boxX: number;
|
|
150
|
+
boxY: number;
|
|
151
|
+
boxWidth: number;
|
|
152
|
+
boxHeight: number;
|
|
153
|
+
name: string;
|
|
154
|
+
type?: string;
|
|
155
|
+
id: string;
|
|
156
|
+
nameMaxLines?: number;
|
|
157
|
+
idMaxLines?: number;
|
|
158
|
+
marginX?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface TextRowGroup {
|
|
162
|
+
cls: string;
|
|
163
|
+
lines: string[];
|
|
164
|
+
lineHeight: number;
|
|
165
|
+
gapAfter: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function totalVerticalSpan(groups: TextRowGroup[]): number {
|
|
169
|
+
let span = 0;
|
|
170
|
+
for (let i = 0; i < groups.length; i++) {
|
|
171
|
+
const g = groups[i];
|
|
172
|
+
if (g.lines.length > 1) span += (g.lines.length - 1) * g.lineHeight;
|
|
173
|
+
if (i < groups.length - 1) span += g.gapAfter;
|
|
174
|
+
}
|
|
175
|
+
return span;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Layout centred entity text (name → optional type → id) inside a fixed box.
|
|
180
|
+
* Returns absolute SVG y-positions (line centres) for each rendered row.
|
|
181
|
+
*/
|
|
182
|
+
export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): TextLineSpec[] {
|
|
183
|
+
const marginX = opts.marginX ?? TEXT_MARGIN_X;
|
|
184
|
+
const innerW = Math.max(0, opts.boxWidth - marginX * 2);
|
|
185
|
+
const cx = opts.boxX + opts.boxWidth / 2;
|
|
186
|
+
const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
|
|
187
|
+
const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
|
|
188
|
+
const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
|
|
189
|
+
|
|
190
|
+
const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 2);
|
|
191
|
+
const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
|
|
192
|
+
const idMaxLines = opts.idMaxLines ?? 1;
|
|
193
|
+
const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
|
|
194
|
+
|
|
195
|
+
const groups: TextRowGroup[] = [
|
|
196
|
+
{
|
|
197
|
+
cls: 'text-primary',
|
|
198
|
+
lines: nameLines,
|
|
199
|
+
lineHeight: LINE_H_PRIMARY,
|
|
200
|
+
gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
if (typeLine) {
|
|
204
|
+
groups.push({
|
|
205
|
+
cls: 'text-secondary',
|
|
206
|
+
lines: [typeLine],
|
|
207
|
+
lineHeight: LINE_H_SECONDARY,
|
|
208
|
+
gapAfter: NAME_ID_GAP,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
groups.push({
|
|
212
|
+
cls: 'text-id',
|
|
213
|
+
lines: idLines,
|
|
214
|
+
lineHeight: LINE_H_ID,
|
|
215
|
+
gapAfter: 0,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const span = totalVerticalSpan(groups);
|
|
219
|
+
const firstY = Math.round(opts.boxY + (opts.boxHeight - span) / 2);
|
|
220
|
+
const out: TextLineSpec[] = [];
|
|
221
|
+
let y = firstY;
|
|
222
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
223
|
+
const g = groups[gi];
|
|
224
|
+
for (let li = 0; li < g.lines.length; li++) {
|
|
225
|
+
out.push({ cls: g.cls, text: g.lines[li], y });
|
|
226
|
+
if (li < g.lines.length - 1) y += g.lineHeight;
|
|
227
|
+
}
|
|
228
|
+
if (gi < groups.length - 1) y += g.gapAfter;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Emit centred `<text>` elements from {@link layoutCenteredEntityText} specs. */
|
|
234
|
+
export function emitCenteredTextSvg(
|
|
235
|
+
specs: TextLineSpec[],
|
|
236
|
+
centerX: number,
|
|
237
|
+
esc: (s: string) => string,
|
|
238
|
+
): string {
|
|
239
|
+
return specs
|
|
240
|
+
.map((spec) => {
|
|
241
|
+
const x = spec.x ?? centerX;
|
|
242
|
+
const anchor = spec.x != null && spec.cls === 'text-pill' ? ' text-anchor="middle"' : ' text-anchor="middle"';
|
|
243
|
+
return `<text class="${spec.cls}" x="${x}" y="${spec.y}"${anchor} dominant-baseline="central">${esc(spec.text)}</text>`;
|
|
244
|
+
})
|
|
245
|
+
.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface LayoutLeafBlockTextOptions {
|
|
249
|
+
boxX: number;
|
|
250
|
+
boxY: number;
|
|
251
|
+
boxWidth: number;
|
|
252
|
+
boxHeight: number;
|
|
253
|
+
name: string;
|
|
254
|
+
id: string;
|
|
255
|
+
nameMaxLines?: number;
|
|
256
|
+
marginX?: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred. */
|
|
260
|
+
export function layoutLeafBlockText(opts: LayoutLeafBlockTextOptions): TextLineSpec[] {
|
|
261
|
+
const marginX = opts.marginX ?? TEXT_MARGIN_X;
|
|
262
|
+
const innerW = Math.max(0, opts.boxWidth - marginX * 2);
|
|
263
|
+
const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
|
|
264
|
+
const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
|
|
265
|
+
const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 3);
|
|
266
|
+
const idLines = wrapId(opts.id, maxCharsId, 2);
|
|
267
|
+
|
|
268
|
+
const nameSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
|
|
269
|
+
const idSpan = (idLines.length - 1) * LINE_H_ID;
|
|
270
|
+
const totalSpan = nameSpan + NAME_ID_GAP + idSpan;
|
|
271
|
+
const firstY = Math.round(opts.boxY + (opts.boxHeight - totalSpan) / 2);
|
|
272
|
+
|
|
273
|
+
const out: TextLineSpec[] = [];
|
|
274
|
+
for (let i = 0; i < nameLines.length; i++) {
|
|
275
|
+
out.push({ cls: 'text-primary', text: nameLines[i], y: firstY + i * LINE_H_PRIMARY });
|
|
276
|
+
}
|
|
277
|
+
const idFirstY = firstY + nameSpan + NAME_ID_GAP;
|
|
278
|
+
for (let i = 0; i < idLines.length; i++) {
|
|
279
|
+
out.push({ cls: 'text-id', text: idLines[i], y: idFirstY + i * LINE_H_ID });
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface LayoutHeaderBlockTextOptions {
|
|
285
|
+
boxX: number;
|
|
286
|
+
boxY: number;
|
|
287
|
+
boxWidth: number;
|
|
288
|
+
headerHeight: number;
|
|
289
|
+
name: string;
|
|
290
|
+
id: string;
|
|
291
|
+
marginX?: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Container header: single-line name + id stacked in the header strip. */
|
|
295
|
+
export function layoutHeaderBlockText(opts: LayoutHeaderBlockTextOptions): TextLineSpec[] {
|
|
296
|
+
const marginX = opts.marginX ?? TEXT_MARGIN_X;
|
|
297
|
+
const innerW = Math.max(0, opts.boxWidth - marginX * 2);
|
|
298
|
+
const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
|
|
299
|
+
const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
|
|
300
|
+
const nameText = truncateLine(opts.name, maxCharsName);
|
|
301
|
+
const idText = truncateId(opts.id, maxCharsId);
|
|
302
|
+
const headerMid = opts.boxY + opts.headerHeight / 2;
|
|
303
|
+
const halfSpan = (LINE_H_PRIMARY + LINE_H_ID) / 4;
|
|
304
|
+
return [
|
|
305
|
+
{ cls: 'text-primary', text: nameText, y: Math.round(headerMid - halfSpan) },
|
|
306
|
+
{ cls: 'text-id', text: idText, y: Math.round(headerMid + halfSpan) },
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Left-anchored multi-line cell text, vertically centred (Process Blueprint cells).
|
|
312
|
+
* Returns one spec per line with absolute x baked into caller via anchorX.
|
|
313
|
+
*/
|
|
314
|
+
export function layoutLeftCellLines(
|
|
315
|
+
lines: string[],
|
|
316
|
+
anchorX: number,
|
|
317
|
+
cellTop: number,
|
|
318
|
+
cellHeight: number,
|
|
319
|
+
lineHeight: number,
|
|
320
|
+
cls: string,
|
|
321
|
+
): TextLineSpec[] {
|
|
322
|
+
const ls = lines.length > 0 ? lines : [''];
|
|
323
|
+
const firstY = cellTop + cellHeight / 2 - ((ls.length - 1) / 2) * lineHeight;
|
|
324
|
+
return ls.map((text, i) => ({ cls, text, y: firstY + i * lineHeight, x: anchorX }));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Centred pill text: name lines + optional id line below. */
|
|
328
|
+
export function layoutCenteredPillText(
|
|
329
|
+
pillX: number,
|
|
330
|
+
pillY: number,
|
|
331
|
+
pillWidth: number,
|
|
332
|
+
pillHeight: number,
|
|
333
|
+
nameLines: string[],
|
|
334
|
+
idLine: string | undefined,
|
|
335
|
+
nameLineHeight = 15,
|
|
336
|
+
): TextLineSpec[] {
|
|
337
|
+
const cx = pillX + pillWidth / 2;
|
|
338
|
+
const lineCount = nameLines.length + (idLine ? 1 : 0);
|
|
339
|
+
const firstY = pillY + pillHeight / 2 - ((lineCount - 1) / 2) * nameLineHeight;
|
|
340
|
+
const out: TextLineSpec[] = nameLines.map((text, i) => ({
|
|
341
|
+
cls: 'text-pill',
|
|
342
|
+
text,
|
|
343
|
+
y: firstY + i * nameLineHeight,
|
|
344
|
+
x: cx,
|
|
345
|
+
}));
|
|
346
|
+
if (idLine) {
|
|
347
|
+
out.push({
|
|
348
|
+
cls: 'text-secondary',
|
|
349
|
+
text: idLine,
|
|
350
|
+
y: firstY + nameLines.length * nameLineHeight,
|
|
351
|
+
x: cx,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|