@diagrammo/dgmo 0.32.1 → 0.34.0
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/.cursorrules +7 -2
- package/.github/copilot-instructions.md +7 -2
- package/.windsurfrules +7 -2
- package/SKILL.md +7 -2
- package/dist/advanced.cjs +1434 -443
- package/dist/advanced.d.cts +41 -1
- package/dist/advanced.d.ts +41 -1
- package/dist/advanced.js +1433 -443
- package/dist/auto.cjs +1242 -318
- package/dist/auto.js +91 -91
- package/dist/auto.mjs +1242 -318
- package/dist/cli.cjs +138 -136
- package/dist/index.cjs +1308 -317
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +1307 -317
- package/docs/ai-integration.md +7 -2
- package/docs/language-reference.md +39 -8
- package/package.json +2 -2
- package/src/advanced.ts +6 -1
- package/src/boxes-and-lines/label-placement.ts +339 -0
- package/src/boxes-and-lines/layout-grouped.ts +909 -0
- package/src/boxes-and-lines/layout-search.ts +34 -3
- package/src/boxes-and-lines/layout.ts +32 -7
- package/src/boxes-and-lines/parser.ts +3 -2
- package/src/boxes-and-lines/renderer.ts +67 -139
- package/src/c4/layout.ts +2 -1
- package/src/c4/parser.ts +3 -2
- package/src/c4/renderer.ts +3 -2
- package/src/cli.ts +68 -0
- package/src/er/parser.ts +2 -1
- package/src/er/renderer.ts +2 -1
- package/src/gantt/parser.ts +3 -5
- package/src/gantt/renderer.ts +7 -6
- package/src/index.ts +6 -1
- package/src/infra/parser.ts +2 -1
- package/src/infra/renderer.ts +4 -3
- package/src/journey-map/parser.ts +27 -8
- package/src/journey-map/renderer.ts +26 -9
- package/src/kanban/parser.ts +4 -3
- package/src/kanban/renderer.ts +6 -5
- package/src/map/completion.ts +116 -0
- package/src/map/layout.ts +4 -3
- package/src/map/parser.ts +3 -2
- package/src/mindmap/parser.ts +4 -3
- package/src/mindmap/renderer.ts +2 -1
- package/src/org/layout.ts +3 -2
- package/src/org/parser.ts +3 -2
- package/src/org/renderer.ts +5 -4
- package/src/pert/parser.ts +3 -2
- package/src/sequence/renderer.ts +10 -12
- package/src/sequence/tag-resolution.ts +2 -1
- package/src/sitemap/layout.ts +3 -2
- package/src/sitemap/parser.ts +36 -2
- package/src/sitemap/renderer.ts +12 -6
- package/src/timeline/renderer.ts +4 -3
- package/src/utils/legend-d3.ts +4 -3
- package/src/utils/legend-svg.ts +3 -2
- package/src/utils/tag-groups.ts +28 -7
- package/src/visualizations/parse.ts +2 -1
package/src/org/renderer.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Org Chart SVG Renderer
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Selection from 'd3-selection';
|
|
6
7
|
import { FONT_FAMILY } from '../fonts';
|
|
7
8
|
import {
|
|
@@ -269,7 +270,7 @@ export function renderOrg(
|
|
|
269
270
|
// Build display name map from tag groups (lowercase key → original casing)
|
|
270
271
|
const displayNames = new Map<string, string>();
|
|
271
272
|
for (const group of parsed.tagGroups) {
|
|
272
|
-
displayNames.set(group.name
|
|
273
|
+
displayNames.set(tagAttrKey(group.name), group.name);
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
// Root node IDs — focus icon is suppressed on these (already the tree root)
|
|
@@ -288,7 +289,7 @@ export function renderOrg(
|
|
|
288
289
|
// Use tagMetadata (unfiltered) so hover-highlight works even when the
|
|
289
290
|
// active tag group is hidden from the visible card body via the eye toggle.
|
|
290
291
|
if (activeTagGroup) {
|
|
291
|
-
const tagKey = activeTagGroup
|
|
292
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
292
293
|
const metaValue = c.tagMetadata[tagKey];
|
|
293
294
|
if (metaValue) {
|
|
294
295
|
cG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
|
|
@@ -466,7 +467,7 @@ export function renderOrg(
|
|
|
466
467
|
// Use tagMetadata (unfiltered) so hover-highlight works even when the
|
|
467
468
|
// active tag group is hidden from the visible card body via the eye toggle.
|
|
468
469
|
if (activeTagGroup) {
|
|
469
|
-
const tagKey = activeTagGroup
|
|
470
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
470
471
|
const metaValue = node.tagMetadata[tagKey];
|
|
471
472
|
if (metaValue) {
|
|
472
473
|
nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
|
|
@@ -781,7 +782,7 @@ export function renderOrg(
|
|
|
781
782
|
const computedLayout = legendHandle.getLayout();
|
|
782
783
|
if (computedLayout.activeCapsule?.addonX != null) {
|
|
783
784
|
const capsule = computedLayout.activeCapsule;
|
|
784
|
-
const groupKey = capsule.groupName
|
|
785
|
+
const groupKey = tagAttrKey(capsule.groupName);
|
|
785
786
|
const isHidden = hiddenAttributes?.has(groupKey) ?? false;
|
|
786
787
|
|
|
787
788
|
// Find the rendered active group <g> and append eye icon
|
package/src/pert/parser.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
finalizeAutoTagColors,
|
|
37
37
|
AUTO_TAG_COLOR_SENTINEL,
|
|
38
38
|
type TagGroup,
|
|
39
|
+
tagAttrKey,
|
|
39
40
|
} from '../utils/tag-groups';
|
|
40
41
|
import type { Writable } from '../utils/brand';
|
|
41
42
|
import type { Duration, DurationUnit } from '../gantt/types';
|
|
@@ -695,12 +696,12 @@ export function parsePert(
|
|
|
695
696
|
if (tagBlockMatch.alias) {
|
|
696
697
|
metaAliasMap.set(
|
|
697
698
|
tagBlockMatch.alias.toLowerCase(),
|
|
698
|
-
tagBlockMatch.name
|
|
699
|
+
tagAttrKey(tagBlockMatch.name)
|
|
699
700
|
);
|
|
700
701
|
}
|
|
701
702
|
metaAliasMap.set(
|
|
702
703
|
normalizeName(tagBlockMatch.name),
|
|
703
|
-
tagBlockMatch.name
|
|
704
|
+
tagAttrKey(tagBlockMatch.name)
|
|
704
705
|
);
|
|
705
706
|
tagGroups.push(currentTagGroup);
|
|
706
707
|
// Inline values (e.g. `tag Priority as p Low green, High red`).
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// Sequence Diagram SVG Renderer
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Selection from 'd3-selection';
|
|
6
7
|
import type { PaletteColors } from '../palettes';
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
contrastText,
|
|
10
|
+
mix,
|
|
11
|
+
shapeFill,
|
|
12
|
+
themeBaseBg,
|
|
13
|
+
} from '../palettes/color-utils';
|
|
8
14
|
import {
|
|
9
15
|
parseInlineMarkdown,
|
|
10
16
|
truncateBareUrl,
|
|
@@ -1062,7 +1068,7 @@ export function renderSequenceDiagram(
|
|
|
1062
1068
|
}
|
|
1063
1069
|
const getTagColor = (value: string | undefined): string | undefined =>
|
|
1064
1070
|
value ? tagValueToColor.get(value.toLowerCase()) : undefined;
|
|
1065
|
-
const tagKey = activeTagGroup
|
|
1071
|
+
const tagKey = activeTagGroup ? tagAttrKey(activeTagGroup) : undefined;
|
|
1066
1072
|
|
|
1067
1073
|
// Build hidden message set for collapse support
|
|
1068
1074
|
const hiddenMsgIndices = new Set<number>();
|
|
@@ -2008,11 +2014,7 @@ export function renderSequenceDiagram(
|
|
|
2008
2014
|
const groupTagValue = tagKey && group.metadata?.[tagKey];
|
|
2009
2015
|
const groupTagColor = getTagColor(groupTagValue || undefined);
|
|
2010
2016
|
const fillColor = groupTagColor
|
|
2011
|
-
? mix(
|
|
2012
|
-
groupTagColor,
|
|
2013
|
-
themeBaseBg(palette, isDark),
|
|
2014
|
-
isDark ? 15 : 20
|
|
2015
|
-
)
|
|
2017
|
+
? mix(groupTagColor, themeBaseBg(palette, isDark), isDark ? 15 : 20)
|
|
2016
2018
|
: isDark
|
|
2017
2019
|
? palette.surface
|
|
2018
2020
|
: palette.bg;
|
|
@@ -2137,11 +2139,7 @@ export function renderSequenceDiagram(
|
|
|
2137
2139
|
|
|
2138
2140
|
// Overlay a taller rect to replace the standard participant box
|
|
2139
2141
|
const pFill = effectiveTagColor
|
|
2140
|
-
? mix(
|
|
2141
|
-
effectiveTagColor,
|
|
2142
|
-
themeBaseBg(palette, isDark),
|
|
2143
|
-
isDark ? 30 : 40
|
|
2144
|
-
)
|
|
2142
|
+
? mix(effectiveTagColor, themeBaseBg(palette, isDark), isDark ? 30 : 40)
|
|
2145
2143
|
: isDark
|
|
2146
2144
|
? mix(palette.overlay, palette.surface, 50)
|
|
2147
2145
|
: mix(palette.bg, palette.surface, 50);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Resolves effective tag values for participants and messages
|
|
6
6
|
// using the priority chain: explicit > group > receiver-inherit > default > neutral
|
|
7
7
|
|
|
8
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
8
9
|
import type {
|
|
9
10
|
ParsedSequenceDgmo,
|
|
10
11
|
SequenceParticipant,
|
|
@@ -116,7 +117,7 @@ export function resolveSequenceTags(
|
|
|
116
117
|
return result;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
const groupKey = group.name
|
|
120
|
+
const groupKey = tagAttrKey(group.name);
|
|
120
121
|
|
|
121
122
|
// Clone participant metadata (don't mutate parsed data)
|
|
122
123
|
const participantMeta = new Map<string, Record<string, string>>();
|
package/src/sitemap/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Sitemap Diagram Layout Engine (Dagre flat graph)
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import dagre from '@dagrejs/dagre';
|
|
6
7
|
import type { Writable } from '../utils/brand';
|
|
7
8
|
import type { ParsedSitemap, SitemapNode } from './types';
|
|
@@ -232,7 +233,7 @@ function computeLegendGroups(
|
|
|
232
233
|
for (const group of tagGroups) {
|
|
233
234
|
if (group.entries.length === 0) continue;
|
|
234
235
|
|
|
235
|
-
const usedValues = usedValuesByGroup?.get(group.name
|
|
236
|
+
const usedValues = usedValuesByGroup?.get(tagAttrKey(group.name));
|
|
236
237
|
const visibleEntries = usedValues
|
|
237
238
|
? group.entries.filter((e) => usedValues.has(e.value.toLowerCase()))
|
|
238
239
|
: group.entries;
|
|
@@ -866,7 +867,7 @@ export function layoutSitemap(
|
|
|
866
867
|
// Collect used tag values
|
|
867
868
|
const usedValuesByGroup = new Map<string, Set<string>>();
|
|
868
869
|
for (const group of parsed.tagGroups) {
|
|
869
|
-
const key = group.name
|
|
870
|
+
const key = tagAttrKey(group.name);
|
|
870
871
|
const used = new Set<string>();
|
|
871
872
|
const walk = (node: SitemapNode) => {
|
|
872
873
|
if (!node.isContainer && node.metadata[key]) {
|
package/src/sitemap/parser.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
stripDefaultModifier,
|
|
30
30
|
finalizeAutoTagColors,
|
|
31
31
|
AUTO_TAG_COLOR_SENTINEL,
|
|
32
|
+
tagAttrKey,
|
|
32
33
|
} from '../utils/tag-groups';
|
|
33
34
|
import {
|
|
34
35
|
measureIndent,
|
|
@@ -273,12 +274,12 @@ export function parseSitemap(
|
|
|
273
274
|
if (tagBlockMatch.alias) {
|
|
274
275
|
metaAliasMap.set(
|
|
275
276
|
normalizeName(tagBlockMatch.alias),
|
|
276
|
-
tagBlockMatch.name
|
|
277
|
+
tagAttrKey(tagBlockMatch.name)
|
|
277
278
|
);
|
|
278
279
|
}
|
|
279
280
|
metaAliasMap.set(
|
|
280
281
|
normalizeName(tagBlockMatch.name),
|
|
281
|
-
tagBlockMatch.name
|
|
282
|
+
tagAttrKey(tagBlockMatch.name)
|
|
282
283
|
);
|
|
283
284
|
result.tagGroups.push(newTagGroup);
|
|
284
285
|
continue;
|
|
@@ -501,6 +502,16 @@ export function parseSitemap(
|
|
|
501
502
|
}
|
|
502
503
|
}
|
|
503
504
|
|
|
505
|
+
// §12: reject sub-pages nested under a page inside a container.
|
|
506
|
+
const enclosingContainer = nestedPageInsideContainer(indent, indentStack);
|
|
507
|
+
if (enclosingContainer) {
|
|
508
|
+
pushError(
|
|
509
|
+
lineNumber,
|
|
510
|
+
`Pages inside container '[${enclosingContainer}]' cannot have indented sub-pages. Containers hold a flat list of pages.`
|
|
511
|
+
);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
504
515
|
// Node label — possibly with pipe-delimited metadata
|
|
505
516
|
const node = parseNodeLabel(
|
|
506
517
|
trimmed,
|
|
@@ -702,6 +713,29 @@ function attachNode(
|
|
|
702
713
|
indentStack.push({ node, indent });
|
|
703
714
|
}
|
|
704
715
|
|
|
716
|
+
/**
|
|
717
|
+
* §12: containers hold a flat list of pages — a page inside a `[Container]`
|
|
718
|
+
* may not have indented sub-pages. Returns the enclosing container's label
|
|
719
|
+
* when attaching a page at `indent` would nest it under a page that itself
|
|
720
|
+
* lives inside a container; otherwise null. Non-destructive (mirrors
|
|
721
|
+
* `attachNode`'s pop logic without mutating the stack).
|
|
722
|
+
*/
|
|
723
|
+
function nestedPageInsideContainer(
|
|
724
|
+
indent: number,
|
|
725
|
+
indentStack: { node: Writable<SitemapNode>; indent: number }[]
|
|
726
|
+
): string | null {
|
|
727
|
+
let idx = indentStack.length - 1;
|
|
728
|
+
while (idx >= 0 && indentStack[idx]!.indent >= indent) idx--;
|
|
729
|
+
if (idx < 0) return null;
|
|
730
|
+
// Immediate parent. A container parent → flat member, allowed.
|
|
731
|
+
if (indentStack[idx]!.node.isContainer) return null;
|
|
732
|
+
// Parent is a page; flag only when an ancestor is a container.
|
|
733
|
+
for (let k = idx - 1; k >= 0; k--) {
|
|
734
|
+
if (indentStack[k]!.node.isContainer) return indentStack[k]!.node.label;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
705
739
|
function findParentNode(
|
|
706
740
|
indent: number,
|
|
707
741
|
indentStack: { node: Writable<SitemapNode>; indent: number }[]
|
package/src/sitemap/renderer.ts
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
// Sitemap Diagram SVG Renderer
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Selection from 'd3-selection';
|
|
6
7
|
import * as d3Shape from 'd3-shape';
|
|
7
8
|
import { FONT_FAMILY } from '../fonts';
|
|
8
9
|
import type { PaletteColors } from '../palettes';
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
contrastText,
|
|
12
|
+
mix,
|
|
13
|
+
shapeFill,
|
|
14
|
+
themeBaseBg,
|
|
15
|
+
} from '../palettes/color-utils';
|
|
10
16
|
import type { ParsedSitemap } from './types';
|
|
11
17
|
import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
|
|
12
18
|
import { renderInlineText } from '../utils/inline-markdown';
|
|
@@ -284,10 +290,10 @@ export function renderSitemap(
|
|
|
284
290
|
// tagColors: "groupkey:valueLower" → hex color
|
|
285
291
|
const tagColors = new Map<string, string>();
|
|
286
292
|
for (const group of parsed.tagGroups) {
|
|
287
|
-
displayNames.set(group.name
|
|
293
|
+
displayNames.set(tagAttrKey(group.name), group.name);
|
|
288
294
|
for (const entry of group.entries) {
|
|
289
295
|
tagColors.set(
|
|
290
|
-
`${group.name
|
|
296
|
+
`${tagAttrKey(group.name)}:${entry.value.toLowerCase()}`,
|
|
291
297
|
entry.color
|
|
292
298
|
);
|
|
293
299
|
}
|
|
@@ -317,7 +323,7 @@ export function renderSitemap(
|
|
|
317
323
|
|
|
318
324
|
// Tag metadata for legend hover dimming
|
|
319
325
|
if (activeTagGroup) {
|
|
320
|
-
const tagKey = activeTagGroup
|
|
326
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
321
327
|
const tagVal = c.tagMetadata[tagKey];
|
|
322
328
|
if (tagVal) cG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
|
|
323
329
|
}
|
|
@@ -486,7 +492,7 @@ export function renderSitemap(
|
|
|
486
492
|
|
|
487
493
|
// Tag metadata for legend hover dimming
|
|
488
494
|
if (activeTagGroup) {
|
|
489
|
-
const tagKey = activeTagGroup
|
|
495
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
490
496
|
const tagVal = node.tagMetadata[tagKey];
|
|
491
497
|
if (tagVal) nodeG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
|
|
492
498
|
}
|
|
@@ -721,7 +727,7 @@ function renderLegend(
|
|
|
721
727
|
const computedLayout = legendHandle.getLayout();
|
|
722
728
|
if (computedLayout.activeCapsule?.addonX != null) {
|
|
723
729
|
const capsule = computedLayout.activeCapsule;
|
|
724
|
-
const groupKey = capsule.groupName
|
|
730
|
+
const groupKey = tagAttrKey(capsule.groupName);
|
|
725
731
|
const isHidden = hiddenAttributes?.has(groupKey) ?? false;
|
|
726
732
|
|
|
727
733
|
const activeGroupEl = parent.select(`[data-legend-group="${groupKey}"]`);
|
package/src/timeline/renderer.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Timeline renderer — Story 109.2 (arch-review). Extracted from d3.ts.
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Scale from 'd3-scale';
|
|
6
7
|
import * as d3Selection from 'd3-selection';
|
|
7
8
|
import { FONT_FAMILY } from '../fonts';
|
|
@@ -820,7 +821,7 @@ function setupTimeline(
|
|
|
820
821
|
let tagLanes: Lane[] | null = null;
|
|
821
822
|
|
|
822
823
|
if (resolvedSwimlaneTG) {
|
|
823
|
-
const tagKey = resolvedSwimlaneTG
|
|
824
|
+
const tagKey = tagAttrKey(resolvedSwimlaneTG);
|
|
824
825
|
const tagGroup = parsed.timelineTagGroups.find(
|
|
825
826
|
(g) => g.name.toLowerCase() === tagKey
|
|
826
827
|
);
|
|
@@ -1322,7 +1323,7 @@ function renderTimelineTagLegendOverlay(
|
|
|
1322
1323
|
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
1323
1324
|
},
|
|
1324
1325
|
onEntryHover: (groupName, entryValue) => {
|
|
1325
|
-
const tagKey = groupName
|
|
1326
|
+
const tagKey = tagAttrKey(groupName);
|
|
1326
1327
|
if (entryValue) {
|
|
1327
1328
|
const tagVal = entryValue.toLowerCase();
|
|
1328
1329
|
fadeToTagValue(mainG, tagKey, tagVal);
|
|
@@ -1349,7 +1350,7 @@ function renderTimelineTagLegendOverlay(
|
|
|
1349
1350
|
}
|
|
1350
1351
|
},
|
|
1351
1352
|
onGroupRendered: (groupName, groupEl, isActive) => {
|
|
1352
|
-
const groupKey = groupName
|
|
1353
|
+
const groupKey = tagAttrKey(groupName);
|
|
1353
1354
|
groupEl.attr('data-tag-group', groupKey);
|
|
1354
1355
|
if (isActive && !viewMode) {
|
|
1355
1356
|
const isSwimActive =
|
package/src/utils/legend-d3.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Thin adapter: layout engine → D3 DOM
|
|
4
4
|
// ============================================================
|
|
5
5
|
|
|
6
|
+
import { tagAttrKey } from './tag-groups';
|
|
6
7
|
import {
|
|
7
8
|
LEGEND_HEIGHT,
|
|
8
9
|
LEGEND_PILL_FONT_SIZE,
|
|
@@ -164,7 +165,7 @@ function renderCapsule(
|
|
|
164
165
|
const g = parent
|
|
165
166
|
.append('g')
|
|
166
167
|
.attr('transform', `translate(${capsule.x},${capsule.y})`)
|
|
167
|
-
.attr('data-legend-group', capsule.groupName
|
|
168
|
+
.attr('data-legend-group', tagAttrKey(capsule.groupName))
|
|
168
169
|
.style('cursor', 'pointer');
|
|
169
170
|
|
|
170
171
|
// Outer capsule background
|
|
@@ -211,7 +212,7 @@ function renderCapsule(
|
|
|
211
212
|
// Continuous ramp (choropleth group): min | gradient | max, in place of dots.
|
|
212
213
|
if (capsule.gradient) {
|
|
213
214
|
const gr = capsule.gradient;
|
|
214
|
-
const gradId = `dgmo-legend-ramp-${capsule.groupName
|
|
215
|
+
const gradId = `dgmo-legend-ramp-${tagAttrKey(capsule.groupName).replace(/[^a-z0-9]+/g, '-')}`;
|
|
215
216
|
const def = g.append('defs').append('linearGradient').attr('id', gradId);
|
|
216
217
|
// Sample the SAME ramp the fills use (direct = 2 endpoints; diverging =
|
|
217
218
|
// stops through the neutral midpoint), so the legend matches the basemap.
|
|
@@ -318,7 +319,7 @@ function renderPill(
|
|
|
318
319
|
const g = parent
|
|
319
320
|
.append('g')
|
|
320
321
|
.attr('transform', `translate(${pill.x},${pill.y})`)
|
|
321
|
-
.attr('data-legend-group', pill.groupName
|
|
322
|
+
.attr('data-legend-group', tagAttrKey(pill.groupName))
|
|
322
323
|
.style('cursor', 'pointer');
|
|
323
324
|
|
|
324
325
|
g.append('rect')
|
package/src/utils/legend-svg.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (not dominant-baseline) so resvg renders it correctly in PNG export.
|
|
13
13
|
// ============================================================
|
|
14
14
|
|
|
15
|
+
import { tagAttrKey } from './tag-groups';
|
|
15
16
|
import type {
|
|
16
17
|
LegendConfig,
|
|
17
18
|
LegendState,
|
|
@@ -99,7 +100,7 @@ function emitCapsule(
|
|
|
99
100
|
);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
return `<g transform="translate(${capsule.x},${capsule.y})" data-legend-group="${esc(capsule.groupName
|
|
103
|
+
return `<g transform="translate(${capsule.x},${capsule.y})" data-legend-group="${esc(tagAttrKey(capsule.groupName))}" style="cursor:pointer">${inner.join('')}</g>`;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/** Collapsed group pill (no active group) → SVG string. */
|
|
@@ -109,7 +110,7 @@ function emitPill(
|
|
|
109
110
|
groupBg: string
|
|
110
111
|
): string {
|
|
111
112
|
return (
|
|
112
|
-
`<g transform="translate(${pill.x},${pill.y})" data-legend-group="${esc(pill.groupName
|
|
113
|
+
`<g transform="translate(${pill.x},${pill.y})" data-legend-group="${esc(tagAttrKey(pill.groupName))}" style="cursor:pointer">` +
|
|
113
114
|
`<rect width="${pill.width}" height="${pill.height}" rx="${pill.height / 2}" fill="${esc(groupBg)}"/>` +
|
|
114
115
|
`<text x="${pill.width / 2}" y="${pill.height / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2}" font-size="${LEGEND_PILL_FONT_SIZE}" font-weight="500" fill="${esc(palette.textMuted)}" text-anchor="middle" font-family="${esc(FONT_FAMILY)}">${esc(pill.groupName)}</text>` +
|
|
115
116
|
`</g>`
|
package/src/utils/tag-groups.ts
CHANGED
|
@@ -39,6 +39,23 @@ export interface TagGroup {
|
|
|
39
39
|
readonly lineNumber: number;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* DOM-safe key for a tag group — used wherever the group name becomes a
|
|
44
|
+
* `data-tag-*` attribute suffix, an entity metadata key, or an `active-tag`
|
|
45
|
+
* match target. For a single-identifier name (the only form the parser used to
|
|
46
|
+
* accept) this is exactly `name.toLowerCase()`, so swapping it in for existing
|
|
47
|
+
* diagrams is byte-identical. A quoted multi-word name (`tag "Trust Zone" as tz`)
|
|
48
|
+
* slugs to a hyphenated identifier (`trust-zone`) so it never produces an
|
|
49
|
+
* invalid DOM attribute name; the original `name` is kept for the legend label.
|
|
50
|
+
*/
|
|
51
|
+
export function tagAttrKey(name: string): string {
|
|
52
|
+
return name
|
|
53
|
+
.trim()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/** Result of matching a tag block heading */
|
|
43
60
|
interface TagBlockMatch {
|
|
44
61
|
name: string;
|
|
@@ -450,12 +467,12 @@ export function resolveTagColor(
|
|
|
450
467
|
if (!activeGroupName) return undefined;
|
|
451
468
|
|
|
452
469
|
const group = tagGroups.find(
|
|
453
|
-
(g) => g.name
|
|
470
|
+
(g) => tagAttrKey(g.name) === tagAttrKey(activeGroupName)
|
|
454
471
|
);
|
|
455
472
|
if (!group) return undefined;
|
|
456
473
|
|
|
457
474
|
const metaValue =
|
|
458
|
-
metadata[group.name
|
|
475
|
+
metadata[tagAttrKey(group.name)] ??
|
|
459
476
|
(isContainer ? undefined : group.defaultValue);
|
|
460
477
|
if (!metaValue) return '#999999';
|
|
461
478
|
|
|
@@ -489,7 +506,7 @@ export function validateTagValues(
|
|
|
489
506
|
if (tagGroups.length === 0) return;
|
|
490
507
|
|
|
491
508
|
const groupMap = new Map<string, TagGroup>();
|
|
492
|
-
for (const g of tagGroups) groupMap.set(g.name
|
|
509
|
+
for (const g of tagGroups) groupMap.set(tagAttrKey(g.name), g);
|
|
493
510
|
|
|
494
511
|
for (const entity of entities) {
|
|
495
512
|
for (const [key, value] of Object.entries(entity.metadata)) {
|
|
@@ -557,10 +574,14 @@ export function validateTagGroupNames(
|
|
|
557
574
|
`'none' is a reserved keyword and cannot be used as a tag group name`
|
|
558
575
|
);
|
|
559
576
|
}
|
|
560
|
-
|
|
577
|
+
// A single-identifier name is valid as-is; a multi-word name is valid when
|
|
578
|
+
// quoted (`tag "Trust Zone" as tz`) — it slugs to a DOM-safe key while the
|
|
579
|
+
// original text stays the legend label. Reject only names whose slug is
|
|
580
|
+
// not a usable identifier (e.g. all-punctuation, or leading digit).
|
|
581
|
+
if (!VALID_TAG_IDENT_RE.test(tagAttrKey(group.name))) {
|
|
561
582
|
report(
|
|
562
583
|
group.lineNumber,
|
|
563
|
-
`Tag group name "${group.name}"
|
|
584
|
+
`Tag group name "${group.name}" can't form a valid key — use letters, digits, underscore, or hyphen, and quote a multi-word name (e.g. tag "Trust Zone" as tz)`
|
|
564
585
|
);
|
|
565
586
|
}
|
|
566
587
|
if (group.alias != null && !VALID_TAG_IDENT_RE.test(group.alias)) {
|
|
@@ -591,7 +612,7 @@ export function validateTagGroupNames(
|
|
|
591
612
|
export function cascadeTagMetadata<
|
|
592
613
|
T extends { metadata: Record<string, string>; children: readonly T[] },
|
|
593
614
|
>(roots: readonly T[], tagGroups: ReadonlyArray<{ name: string }>): void {
|
|
594
|
-
const keys = tagGroups.map((g) => g.name
|
|
615
|
+
const keys = tagGroups.map((g) => tagAttrKey(g.name));
|
|
595
616
|
if (keys.length === 0) return;
|
|
596
617
|
|
|
597
618
|
const walk = (node: T, inherited: Record<string, string>): void => {
|
|
@@ -629,7 +650,7 @@ export function injectDefaultTagMetadata(
|
|
|
629
650
|
for (const group of tagGroups) {
|
|
630
651
|
if (group.defaultValue) {
|
|
631
652
|
defaults.push({
|
|
632
|
-
key: group.name
|
|
653
|
+
key: tagAttrKey(group.name),
|
|
633
654
|
value: group.defaultValue,
|
|
634
655
|
});
|
|
635
656
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
stripDefaultModifier,
|
|
37
37
|
validateTagGroupNames,
|
|
38
38
|
validateTagValues,
|
|
39
|
+
tagAttrKey,
|
|
39
40
|
} from '../utils/tag-groups';
|
|
40
41
|
import type { TagGroup } from '../utils/tag-groups';
|
|
41
42
|
import type { Writable } from '../utils/brand';
|
|
@@ -1364,7 +1365,7 @@ function parseVisualizationFull(
|
|
|
1364
1365
|
);
|
|
1365
1366
|
for (const group of result.timelineTagGroups) {
|
|
1366
1367
|
if (!group.defaultValue) continue;
|
|
1367
|
-
const key = group.name
|
|
1368
|
+
const key = tagAttrKey(group.name);
|
|
1368
1369
|
for (const event of result.timelineEvents) {
|
|
1369
1370
|
if (!event.metadata[key]) {
|
|
1370
1371
|
event.metadata[key] = group.defaultValue;
|