@diagrammo/dgmo 0.8.22 → 0.8.25

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 (90) hide show
  1. package/.claude/commands/dgmo.md +60 -72
  2. package/dist/cli.cjs +123 -116
  3. package/dist/editor.cjs +3 -2
  4. package/dist/editor.cjs.map +1 -1
  5. package/dist/editor.js +3 -2
  6. package/dist/editor.js.map +1 -1
  7. package/dist/highlight.cjs +3 -2
  8. package/dist/highlight.cjs.map +1 -1
  9. package/dist/highlight.js +3 -2
  10. package/dist/highlight.js.map +1 -1
  11. package/dist/index.cjs +1649 -442
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +196 -23
  14. package/dist/index.d.ts +196 -23
  15. package/dist/index.js +1631 -440
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal.cjs +677 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +267 -0
  20. package/dist/internal.d.ts +267 -0
  21. package/dist/internal.js +633 -0
  22. package/dist/internal.js.map +1 -0
  23. package/docs/guide/chart-area.md +17 -17
  24. package/docs/guide/chart-bar-stacked.md +12 -12
  25. package/docs/guide/chart-cycle.md +156 -0
  26. package/docs/guide/chart-doughnut.md +10 -10
  27. package/docs/guide/chart-funnel.md +9 -9
  28. package/docs/guide/chart-heatmap.md +10 -10
  29. package/docs/guide/chart-journey-map.md +179 -0
  30. package/docs/guide/chart-kanban.md +2 -0
  31. package/docs/guide/chart-line.md +19 -19
  32. package/docs/guide/chart-multi-line.md +16 -16
  33. package/docs/guide/chart-pie.md +11 -11
  34. package/docs/guide/chart-polar-area.md +10 -10
  35. package/docs/guide/chart-pyramid.md +111 -0
  36. package/docs/guide/chart-radar.md +9 -9
  37. package/docs/guide/chart-scatter.md +24 -27
  38. package/docs/guide/index.md +3 -3
  39. package/docs/guide/registry.json +5 -0
  40. package/docs/language-reference.md +108 -26
  41. package/fonts/Inter-Bold.ttf +0 -0
  42. package/fonts/Inter-Regular.ttf +0 -0
  43. package/fonts/LICENSE-Inter.txt +92 -0
  44. package/gallery/fixtures/bar-stacked.dgmo +12 -6
  45. package/gallery/fixtures/heatmap.dgmo +12 -6
  46. package/gallery/fixtures/multi-line.dgmo +11 -7
  47. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  48. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  49. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  50. package/gallery/fixtures/quadrant.dgmo +8 -8
  51. package/gallery/fixtures/scatter.dgmo +12 -12
  52. package/package.json +14 -2
  53. package/src/boxes-and-lines/parser.ts +13 -2
  54. package/src/boxes-and-lines/renderer.ts +22 -13
  55. package/src/chart-type-scoring.ts +162 -0
  56. package/src/chart-types.ts +437 -0
  57. package/src/cli.ts +152 -101
  58. package/src/completion.ts +9 -48
  59. package/src/cycle/layout.ts +19 -28
  60. package/src/cycle/renderer.ts +59 -32
  61. package/src/cycle/types.ts +21 -0
  62. package/src/d3.ts +30 -3
  63. package/src/dgmo-router.ts +98 -73
  64. package/src/echarts.ts +1 -1
  65. package/src/editor/keywords.ts +3 -2
  66. package/src/fonts.ts +3 -2
  67. package/src/gantt/parser.ts +5 -1
  68. package/src/index.ts +37 -3
  69. package/src/infra/parser.ts +3 -3
  70. package/src/internal.ts +20 -0
  71. package/src/journey-map/layout.ts +7 -3
  72. package/src/journey-map/parser.ts +5 -1
  73. package/src/journey-map/renderer.ts +112 -47
  74. package/src/kanban/parser.ts +5 -1
  75. package/src/org/collapse.ts +82 -4
  76. package/src/org/parser.ts +1 -1
  77. package/src/org/renderer.ts +221 -4
  78. package/src/pyramid/parser.ts +172 -0
  79. package/src/pyramid/renderer.ts +684 -0
  80. package/src/pyramid/types.ts +28 -0
  81. package/src/render.ts +2 -8
  82. package/src/sequence/parser.ts +64 -22
  83. package/src/sequence/participant-inference.ts +0 -1
  84. package/src/sequence/renderer.ts +97 -265
  85. package/src/sharing.ts +0 -1
  86. package/src/sitemap/parser.ts +1 -1
  87. package/src/tech-radar/interactive.ts +54 -0
  88. package/src/utils/parsing.ts +1 -0
  89. package/src/utils/tag-groups.ts +35 -5
  90. package/src/wireframe/parser.ts +3 -1
@@ -101,8 +101,14 @@ export function renderJourneyMap(
101
101
  .select(container)
102
102
  .append('svg')
103
103
  .attr('xmlns', 'http://www.w3.org/2000/svg')
104
- .attr('width', useContainerFit ? containerW : layout.totalWidth)
105
- .attr('height', useContainerFit ? containerH : layout.totalHeight)
104
+ .attr(
105
+ 'width',
106
+ useContainerFit ? containerW : (exportDims?.width ?? layout.totalWidth)
107
+ )
108
+ .attr(
109
+ 'height',
110
+ useContainerFit ? containerH : (exportDims?.height ?? layout.totalHeight)
111
+ )
106
112
  .attr('viewBox', `0 0 ${layout.totalWidth} ${layout.totalHeight}`)
107
113
  .attr('preserveAspectRatio', 'xMidYMin meet')
108
114
  .attr('font-family', FONT_FAMILY);
@@ -293,7 +299,7 @@ export function renderJourneyMap(
293
299
  .attr('transform', `translate(${legendX},${legendY})`);
294
300
 
295
301
  const legendConfig: LegendConfig = {
296
- groups: allLegendGroups,
302
+ groups: parsed.tagGroups,
297
303
  position: {
298
304
  placement: 'top-center',
299
305
  titleRelation: 'inline-with-title',
@@ -409,7 +415,10 @@ export function renderJourneyMap(
409
415
 
410
416
  // Score label — emotion face icon
411
417
  const SCORE_LABEL_R = 8;
412
- const labelG = curveG.append('g').attr('class', 'journey-score-label');
418
+ const labelG = curveG
419
+ .append('g')
420
+ .attr('class', 'journey-score-label')
421
+ .attr('data-score', String(score));
413
422
  renderScoreFace(
414
423
  labelG,
415
424
  PADDING - SCORE_LABEL_R - 2,
@@ -419,42 +428,9 @@ export function renderJourneyMap(
419
428
  SCORE_LABEL_R
420
429
  );
421
430
 
422
- // Interactive hover: highlight faces + cards matching this score
431
+ // Score label interactivity is wired up in the click-to-lock section below
423
432
  if (!exportDims) {
424
- const scoreStr = String(score);
425
433
  labelG.style('cursor', 'pointer');
426
- labelG.on('mouseenter', () => {
427
- svg.selectAll<SVGGElement, unknown>('.journey-step').each(function () {
428
- const hit = this.getAttribute('data-score') === scoreStr;
429
- d3.select(this).style('opacity', hit ? '1' : String(DIM_HOVER));
430
- });
431
- svg.selectAll<SVGGElement, unknown>('.journey-face').each(function () {
432
- const hit = this.getAttribute('data-score') === scoreStr;
433
- const sel = d3.select(this);
434
- sel.style('opacity', hit ? '1' : String(DIM_HOVER));
435
- if (hit) {
436
- const fcx = parseFloat(sel.attr('data-cx') ?? '0');
437
- const fcy = parseFloat(sel.attr('data-cy') ?? '0');
438
- sel.attr(
439
- 'transform',
440
- `translate(${fcx},${fcy}) scale(1.3) translate(${-fcx},${-fcy})`
441
- );
442
- } else {
443
- sel.attr('transform', null);
444
- }
445
- });
446
- svg
447
- .selectAll<SVGGElement, unknown>('.journey-thought')
448
- .style('opacity', String(DIM_HOVER));
449
- });
450
- labelG.on('mouseleave', () => {
451
- svg.selectAll('.journey-step').style('opacity', null);
452
- svg
453
- .selectAll('.journey-face')
454
- .style('opacity', null)
455
- .attr('transform', null);
456
- svg.selectAll('.journey-thought').style('opacity', null);
457
- });
458
434
  }
459
435
  }
460
436
 
@@ -772,6 +748,52 @@ export function renderJourneyMap(
772
748
  if (!exportDims) {
773
749
  const DIM_OPACITY = 0.35;
774
750
  let lockedLine: number | null = null;
751
+ let lockedScore: number | null = null;
752
+
753
+ // Helper: dim everything except elements matching a score value
754
+ const applyScoreDimming = (activeScore: number) => {
755
+ const scoreStr = String(activeScore);
756
+ svg.selectAll<SVGGElement, unknown>('.journey-step').each(function () {
757
+ const hit = this.getAttribute('data-score') === scoreStr;
758
+ d3.select(this).style('opacity', hit ? '1' : String(DIM_HOVER));
759
+ });
760
+ svg.selectAll<SVGGElement, unknown>('.journey-face').each(function () {
761
+ const hit = this.getAttribute('data-score') === scoreStr;
762
+ const sel = d3.select(this);
763
+ sel.style('opacity', hit ? '1' : String(DIM_HOVER));
764
+ if (hit) {
765
+ const fcx = parseFloat(sel.attr('data-cx') ?? '0');
766
+ const fcy = parseFloat(sel.attr('data-cy') ?? '0');
767
+ sel.attr(
768
+ 'transform',
769
+ `translate(${fcx},${fcy}) scale(1.3) translate(${-fcx},${-fcy})`
770
+ );
771
+ } else {
772
+ sel.attr('transform', null);
773
+ }
774
+ });
775
+ svg
776
+ .selectAll<SVGGElement, unknown>('.journey-thought')
777
+ .style('opacity', String(DIM_HOVER));
778
+ // Highlight the active y-axis score label, dim the rest
779
+ svg
780
+ .selectAll<SVGGElement, unknown>('.journey-score-label')
781
+ .each(function () {
782
+ const sel = d3.select(this);
783
+ const s = sel.attr('data-score');
784
+ sel.style('opacity', s === scoreStr ? '1' : String(DIM_HOVER));
785
+ });
786
+ };
787
+
788
+ const clearScoreDimming = () => {
789
+ svg.selectAll('.journey-step').style('opacity', null);
790
+ svg
791
+ .selectAll('.journey-face')
792
+ .style('opacity', null)
793
+ .attr('transform', null);
794
+ svg.selectAll('.journey-thought').style('opacity', null);
795
+ svg.selectAll('.journey-score-label').style('opacity', null);
796
+ };
775
797
 
776
798
  // Helper: dim everything except elements matching a line number
777
799
  const applyDimming = (activeLine: number) => {
@@ -841,7 +863,7 @@ export function renderJourneyMap(
841
863
  const lines = wrapText(thoughtText, THOUGHT_MAX_W, THOUGHT_FONT);
842
864
  const textW = Math.min(
843
865
  THOUGHT_MAX_W,
844
- Math.max(...lines.map((l) => l.length * 4.8))
866
+ Math.max(...lines.map((l) => l.length * THOUGHT_FONT * 0.6))
845
867
  );
846
868
  const bw = textW + THOUGHT_PAD_X * 2;
847
869
  const bh = lines.length * THOUGHT_LINE_H + THOUGHT_PAD_Y * 2;
@@ -896,10 +918,13 @@ export function renderJourneyMap(
896
918
  if (
897
919
  !target.closest('.journey-face') &&
898
920
  !target.closest('.journey-step') &&
899
- !target.closest('.journey-phase')
921
+ !target.closest('.journey-phase') &&
922
+ !target.closest('.journey-score-label')
900
923
  ) {
901
924
  lockedLine = null;
925
+ lockedScore = null;
902
926
  clearDimming();
927
+ clearScoreDimming();
903
928
  }
904
929
  });
905
930
 
@@ -919,7 +944,7 @@ export function renderJourneyMap(
919
944
  svg.selectAll<SVGGElement, unknown>('.journey-face').each(function () {
920
945
  const el = d3.select<SVGGElement, unknown>(this);
921
946
  el.on('mouseenter', () => {
922
- if (lockedLine !== null) return;
947
+ if (lockedLine !== null || lockedScore !== null) return;
923
948
  const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
924
949
  if (ln) {
925
950
  applyDimming(ln);
@@ -927,11 +952,16 @@ export function renderJourneyMap(
927
952
  }
928
953
  })
929
954
  .on('mouseleave', () => {
930
- if (lockedLine !== null) return;
955
+ if (lockedLine !== null || lockedScore !== null) return;
931
956
  clearDimming();
932
957
  })
933
958
  .on('click', (event: MouseEvent) => {
934
959
  event.stopPropagation();
960
+ if (lockedScore !== null) {
961
+ lockedScore = null;
962
+ clearScoreDimming();
963
+ return;
964
+ }
935
965
  const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
936
966
  if (lockedLine === ln) {
937
967
  lockedLine = null;
@@ -949,7 +979,7 @@ export function renderJourneyMap(
949
979
  svg.selectAll('.journey-step').each(function () {
950
980
  const el = d3.select(this);
951
981
  el.on('mouseenter', () => {
952
- if (lockedLine !== null) return;
982
+ if (lockedLine !== null || lockedScore !== null) return;
953
983
  const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
954
984
  if (ln) {
955
985
  applyDimming(ln);
@@ -957,11 +987,16 @@ export function renderJourneyMap(
957
987
  }
958
988
  })
959
989
  .on('mouseleave', () => {
960
- if (lockedLine !== null) return;
990
+ if (lockedLine !== null || lockedScore !== null) return;
961
991
  clearDimming();
962
992
  })
963
993
  .on('click', (event: MouseEvent) => {
964
994
  event.stopPropagation();
995
+ if (lockedScore !== null) {
996
+ lockedScore = null;
997
+ clearScoreDimming();
998
+ return;
999
+ }
965
1000
  const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
966
1001
  if (lockedLine === ln) {
967
1002
  lockedLine = null;
@@ -974,6 +1009,36 @@ export function renderJourneyMap(
974
1009
  }
975
1010
  });
976
1011
  });
1012
+
1013
+ // Hover + click on y-axis score labels
1014
+ svg
1015
+ .selectAll<SVGGElement, unknown>('.journey-score-label')
1016
+ .each(function () {
1017
+ const el = d3.select<SVGGElement, unknown>(this);
1018
+ const score = parseInt(el.attr('data-score') ?? '0', 10);
1019
+ el.on('mouseenter', () => {
1020
+ if (lockedLine !== null || lockedScore !== null) return;
1021
+ applyScoreDimming(score);
1022
+ })
1023
+ .on('mouseleave', () => {
1024
+ if (lockedLine !== null || lockedScore !== null) return;
1025
+ clearScoreDimming();
1026
+ })
1027
+ .on('click', (event: MouseEvent) => {
1028
+ event.stopPropagation();
1029
+ if (lockedLine !== null) {
1030
+ lockedLine = null;
1031
+ clearDimming();
1032
+ }
1033
+ if (lockedScore === score) {
1034
+ lockedScore = null;
1035
+ clearScoreDimming();
1036
+ } else {
1037
+ lockedScore = score;
1038
+ applyScoreDimming(score);
1039
+ }
1040
+ });
1041
+ });
977
1042
  }
978
1043
  }
979
1044
 
@@ -1326,8 +1391,8 @@ function renderScoreFace(
1326
1391
  return g;
1327
1392
  }
1328
1393
 
1329
- function wrapText(text: string, maxWidth: number, _fontSize: number): string[] {
1330
- const charWidth = 4.8;
1394
+ function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
1395
+ const charWidth = fontSize * 0.6;
1331
1396
  const maxChars = Math.floor(maxWidth / charWidth);
1332
1397
  if (maxChars <= 0) return [text];
1333
1398
 
@@ -1349,7 +1414,7 @@ function wrapText(text: string, maxWidth: number, _fontSize: number): string[] {
1349
1414
  }
1350
1415
 
1351
1416
  function truncateText(text: string, maxWidth: number): string {
1352
- const maxChars = Math.floor(maxWidth / 4.8);
1417
+ const maxChars = Math.floor(maxWidth / 6.6);
1353
1418
  if (text.length <= maxChars) return text;
1354
1419
  return text.substring(0, maxChars - 1) + '\u2026';
1355
1420
  }
@@ -373,7 +373,11 @@ export function parseKanban(
373
373
  return fail(1, 'No columns found. Use [Column Name] to define columns');
374
374
  }
375
375
 
376
- validateTagGroupNames(result.tagGroups, warn);
376
+ validateTagGroupNames(result.tagGroups, warn, (line, msg) => {
377
+ const diag = makeDgmoError(line, msg);
378
+ result.diagnostics.push(diag);
379
+ if (!result.error) result.error = formatDgmoError(diag);
380
+ });
377
381
 
378
382
  return result;
379
383
  }
@@ -15,6 +15,22 @@ export interface CollapsedOrgResult {
15
15
  hiddenCounts: Map<string, number>;
16
16
  }
17
17
 
18
+ export interface AncestorInfo {
19
+ id: string;
20
+ label: string;
21
+ lineNumber: number;
22
+ color?: string;
23
+ metadata: Record<string, string>;
24
+ isContainer: boolean;
25
+ }
26
+
27
+ export interface FocusOrgResult {
28
+ /** ParsedOrg with only the focused subtree as the single root */
29
+ parsed: ParsedOrg;
30
+ /** Ancestor path from original root → parent of focused node (top-down order) */
31
+ ancestorPath: AncestorInfo[];
32
+ }
33
+
18
34
  // ============================================================
19
35
  // Helpers
20
36
  // ============================================================
@@ -56,10 +72,7 @@ function computeHiddenCounts(
56
72
  }
57
73
 
58
74
  /** Remove children of collapsed nodes on the cloned tree. */
59
- function pruneCollapsed(
60
- node: OrgNode,
61
- collapsedIds: Set<string>
62
- ): void {
75
+ function pruneCollapsed(node: OrgNode, collapsedIds: Set<string>): void {
63
76
  for (const child of node.children) {
64
77
  pruneCollapsed(child, collapsedIds);
65
78
  }
@@ -99,3 +112,68 @@ export function collapseOrgTree(
99
112
  hiddenCounts,
100
113
  };
101
114
  }
115
+
116
+ // ============================================================
117
+ // Focus (subtree drill-down)
118
+ // ============================================================
119
+
120
+ /** Find a node by ID and collect the ancestor path leading to it. */
121
+ function findNodeWithPath(
122
+ nodes: OrgNode[],
123
+ targetId: string,
124
+ path: AncestorInfo[]
125
+ ): { node: OrgNode; path: AncestorInfo[] } | null {
126
+ for (const node of nodes) {
127
+ if (node.id === targetId) {
128
+ return { node, path };
129
+ }
130
+ const result = findNodeWithPath(node.children, targetId, [
131
+ ...path,
132
+ {
133
+ id: node.id,
134
+ label: node.label,
135
+ lineNumber: node.lineNumber,
136
+ color: node.color,
137
+ metadata: { ...node.metadata },
138
+ isContainer: node.isContainer,
139
+ },
140
+ ]);
141
+ if (result) return result;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Extract a subtree rooted at `focusNodeId`, returning the focused tree
148
+ * and the ancestor breadcrumb path. Returns null if the node is not found.
149
+ */
150
+ export function focusOrgTree(
151
+ original: ParsedOrg,
152
+ focusNodeId: string
153
+ ): FocusOrgResult | null {
154
+ const found = findNodeWithPath(original.roots, focusNodeId, []);
155
+ if (!found) return null;
156
+
157
+ // If it's already a root, return as-is with empty ancestor path
158
+ const isRoot = original.roots.some((r) => r.id === focusNodeId);
159
+ if (isRoot) {
160
+ return {
161
+ parsed: {
162
+ ...original,
163
+ roots: [cloneNode(found.node)],
164
+ },
165
+ ancestorPath: [],
166
+ };
167
+ }
168
+
169
+ const cloned = cloneNode(found.node);
170
+ cloned.parentId = null;
171
+
172
+ return {
173
+ parsed: {
174
+ ...original,
175
+ roots: [cloned],
176
+ },
177
+ ancestorPath: found.path,
178
+ };
179
+ }
package/src/org/parser.ts CHANGED
@@ -344,7 +344,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
344
344
  collectAll(result.roots);
345
345
 
346
346
  validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
347
- validateTagGroupNames(result.tagGroups, pushWarning);
347
+ validateTagGroupNames(result.tagGroups, pushWarning, pushError);
348
348
  }
349
349
 
350
350
  if (
@@ -10,8 +10,10 @@ import {
10
10
  } from '../utils/export-container';
11
11
  import type { PaletteColors } from '../palettes';
12
12
  import { mix } from '../palettes/color-utils';
13
+ import { resolveTagColor } from '../utils/tag-groups';
13
14
  import type { ParsedOrg } from './parser';
14
15
  import type { OrgLayoutResult } from './layout';
16
+ import type { AncestorInfo } from './collapse';
15
17
  import { parseOrg } from './parser';
16
18
  import { layoutOrg } from './layout';
17
19
  import {
@@ -51,6 +53,12 @@ const CONTAINER_HEADER_HEIGHT = 28;
51
53
  const COLLAPSE_BAR_HEIGHT = 6;
52
54
  const COLLAPSE_BAR_INSET = 0;
53
55
 
56
+ // Ancestor breadcrumb trail (focus mode)
57
+ const ANCESTOR_DOT_R = 4;
58
+ const ANCESTOR_LABEL_FONT_SIZE = 11;
59
+ const ANCESTOR_ROW_HEIGHT = 22;
60
+ const ANCESTOR_TRAIL_BOTTOM_GAP = 16;
61
+
54
62
  const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
55
63
 
56
64
  // ============================================================
@@ -100,7 +108,8 @@ export function renderOrg(
100
108
  onClickItem?: (lineNumber: number) => void,
101
109
  exportDims?: { width?: number; height?: number },
102
110
  activeTagGroup?: string | null,
103
- hiddenAttributes?: Set<string>
111
+ hiddenAttributes?: Set<string>,
112
+ ancestorPath?: AncestorInfo[]
104
113
  ): void {
105
114
  // Clear existing content
106
115
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
@@ -128,9 +137,17 @@ export function renderOrg(
128
137
  const fixedTitle = !exportDims && !!parsed.title;
129
138
  const titleReserve = fixedTitle ? TITLE_HEIGHT : 0;
130
139
 
140
+ // Ancestor breadcrumb trail (focus mode) — rendered inside the scaled group
141
+ const hasAncestorTrail =
142
+ !exportDims && ancestorPath && ancestorPath.length > 0;
143
+ const ancestorTrailHeight = hasAncestorTrail
144
+ ? ancestorPath.length * ANCESTOR_ROW_HEIGHT + ANCESTOR_TRAIL_BOTTOM_GAP
145
+ : 0;
146
+
131
147
  // Compute scale to fit diagram in viewport
132
148
  const diagramW = layout.width;
133
- let diagramH = layout.height + (fixedTitle ? 0 : titleOffset);
149
+ let diagramH =
150
+ layout.height + (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
134
151
  if (fixedLegend) {
135
152
  // Remove the legend space from diagram height — legend is rendered separately
136
153
  diagramH -= layoutLegendShift;
@@ -200,10 +217,11 @@ export function renderOrg(
200
217
  }
201
218
  }
202
219
 
203
- // Content group (offset by title only when title is inside the scaled group)
220
+ // Content group (offset by title + ancestor trail height)
221
+ const contentYShift = (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
204
222
  const contentG = mainG
205
223
  .append('g')
206
- .attr('transform', `translate(0, ${fixedTitle ? 0 : titleOffset})`);
224
+ .attr('transform', `translate(0, ${contentYShift})`);
207
225
 
208
226
  // Build display name map from tag groups (lowercase key → original casing)
209
227
  const displayNames = new Map<string, string>();
@@ -211,6 +229,9 @@ export function renderOrg(
211
229
  displayNames.set(group.name.toLowerCase(), group.name);
212
230
  }
213
231
 
232
+ // Root node IDs — focus icon is suppressed on these (already the tree root)
233
+ const rootNodeIds = new Set(parsed.roots.map((r) => r.id));
234
+
214
235
  // Render container backgrounds (bottom layer)
215
236
  const colorOff = parsed.options?.color === 'off';
216
237
  for (const c of layout.containers) {
@@ -322,6 +343,45 @@ export function renderOrg(
322
343
  .attr('clip-path', `url(#${clipId})`)
323
344
  .attr('class', 'org-collapse-bar');
324
345
  }
346
+
347
+ // Focus icon (hover-reveal, interactive only) — for non-root containers with children
348
+ if (!exportDims && c.hasChildren && !rootNodeIds.has(c.nodeId)) {
349
+ const iconSize = 14;
350
+ const iconPad = 5;
351
+ const iconX = c.width - iconSize - iconPad;
352
+ const iconY = iconPad;
353
+
354
+ const focusG = cG
355
+ .append('g')
356
+ .attr('class', 'org-focus-icon')
357
+ .attr('data-focus-node', c.nodeId)
358
+ .attr('transform', `translate(${iconX}, ${iconY})`);
359
+
360
+ focusG
361
+ .append('rect')
362
+ .attr('x', -3)
363
+ .attr('y', -3)
364
+ .attr('width', iconSize + 6)
365
+ .attr('height', iconSize + 6)
366
+ .attr('fill', 'transparent');
367
+
368
+ const cx = iconSize / 2;
369
+ const cy = iconSize / 2;
370
+ focusG
371
+ .append('circle')
372
+ .attr('cx', cx)
373
+ .attr('cy', cy)
374
+ .attr('r', iconSize / 2 - 1)
375
+ .attr('fill', 'none')
376
+ .attr('stroke', palette.textMuted)
377
+ .attr('stroke-width', 1.5);
378
+ focusG
379
+ .append('circle')
380
+ .attr('cx', cx)
381
+ .attr('cy', cy)
382
+ .attr('r', 2)
383
+ .attr('fill', palette.textMuted);
384
+ }
325
385
  }
326
386
 
327
387
  // Render edges
@@ -479,6 +539,163 @@ export function renderOrg(
479
539
  .attr('clip-path', `url(#${clipId})`)
480
540
  .attr('class', 'org-collapse-bar');
481
541
  }
542
+
543
+ // Focus icon (hover-reveal, interactive only) — for non-root nodes with children
544
+ if (!exportDims && node.hasChildren && !rootNodeIds.has(node.id)) {
545
+ const iconSize = 14;
546
+ const iconPad = 5;
547
+ const iconX = node.width - iconSize - iconPad;
548
+ const iconY = iconPad;
549
+
550
+ const focusG = nodeG
551
+ .append('g')
552
+ .attr('class', 'org-focus-icon')
553
+ .attr('data-focus-node', node.id)
554
+ .attr('transform', `translate(${iconX}, ${iconY})`);
555
+
556
+ // Hit area
557
+ focusG
558
+ .append('rect')
559
+ .attr('x', -3)
560
+ .attr('y', -3)
561
+ .attr('width', iconSize + 6)
562
+ .attr('height', iconSize + 6)
563
+ .attr('fill', 'transparent');
564
+
565
+ // Scope/target icon: outer circle + inner dot
566
+ const cx = iconSize / 2;
567
+ const cy = iconSize / 2;
568
+ focusG
569
+ .append('circle')
570
+ .attr('cx', cx)
571
+ .attr('cy', cy)
572
+ .attr('r', iconSize / 2 - 1)
573
+ .attr('fill', 'none')
574
+ .attr('stroke', palette.textMuted)
575
+ .attr('stroke-width', 1.5);
576
+ focusG
577
+ .append('circle')
578
+ .attr('cx', cx)
579
+ .attr('cy', cy)
580
+ .attr('r', 2)
581
+ .attr('fill', palette.textMuted);
582
+ }
583
+ }
584
+
585
+ // Render ancestor breadcrumb trail (focus mode) — inside scaled group,
586
+ // centered on and connected to the root node
587
+ if (hasAncestorTrail) {
588
+ // Find the root node/container position in the layout
589
+ const rootNode = layout.nodes.find((n) => rootNodeIds.has(n.id));
590
+ const rootContainer = !rootNode
591
+ ? layout.containers.find((c) => rootNodeIds.has(c.nodeId))
592
+ : null;
593
+ // Nodes: x is center. Containers: x is left edge, so center = x + width/2
594
+ const rootCenterX = rootNode
595
+ ? rootNode.x
596
+ : rootContainer
597
+ ? rootContainer.x + rootContainer.width / 2
598
+ : null;
599
+ const rootTopY = rootNode
600
+ ? rootNode.y
601
+ : rootContainer
602
+ ? rootContainer.y
603
+ : null;
604
+ if (rootCenterX !== null && rootTopY !== null) {
605
+ // Trail connects directly to the top edge of the root node.
606
+ // The last ancestor dot sits ANCESTOR_TRAIL_BOTTOM_GAP above the root.
607
+ const trailBottomY = rootTopY - ANCESTOR_TRAIL_BOTTOM_GAP;
608
+
609
+ const trailG = contentG.append('g').attr('class', 'org-ancestor-trail');
610
+
611
+ const count = ancestorPath!.length;
612
+
613
+ // Compute dot positions (top-down order, topmost ancestor highest)
614
+ const dotPositions: number[] = [];
615
+ for (let i = 0; i < count; i++) {
616
+ const fromBottom = count - 1 - i;
617
+ dotPositions.push(trailBottomY - fromBottom * ANCESTOR_ROW_HEIGHT);
618
+ }
619
+
620
+ // Single continuous line from topmost dot to root node top edge
621
+ const lineTopY = dotPositions[0];
622
+ trailG
623
+ .append('line')
624
+ .attr('x1', rootCenterX)
625
+ .attr('y1', lineTopY)
626
+ .attr('x2', rootCenterX)
627
+ .attr('y2', rootTopY)
628
+ .attr('stroke', palette.textMuted)
629
+ .attr('stroke-width', 1.5)
630
+ .attr('stroke-opacity', 0.4);
631
+
632
+ // Dots and labels on top of the line
633
+ for (let i = 0; i < count; i++) {
634
+ const ancestor = ancestorPath![i];
635
+ const dotY = dotPositions[i];
636
+
637
+ // Resolve color from tag groups (same logic as node cards)
638
+ const resolvedColor =
639
+ ancestor.color ??
640
+ resolveTagColor(
641
+ ancestor.metadata,
642
+ parsed.tagGroups,
643
+ activeTagGroup ?? null,
644
+ ancestor.isContainer
645
+ );
646
+ const dotColor = resolvedColor ?? palette.textMuted;
647
+
648
+ const rowG = trailG
649
+ .append('g')
650
+ .attr('class', 'org-ancestor-node')
651
+ .attr('data-focus-ancestor', ancestor.id)
652
+ .style('cursor', 'pointer')
653
+ .attr('transform', `translate(${rootCenterX}, ${dotY})`);
654
+
655
+ // Hit area
656
+ rowG
657
+ .append('rect')
658
+ .attr('x', -ANCESTOR_DOT_R - 2)
659
+ .attr('y', -ANCESTOR_DOT_R - 2)
660
+ .attr('width', 120)
661
+ .attr('height', ANCESTOR_DOT_R * 2 + 4)
662
+ .attr('fill', 'transparent');
663
+
664
+ // Dot — colored by tag group value
665
+ rowG
666
+ .append('circle')
667
+ .attr('cx', 0)
668
+ .attr('cy', 0)
669
+ .attr('r', ANCESTOR_DOT_R)
670
+ .attr('fill', dotColor);
671
+
672
+ // Label
673
+ rowG
674
+ .append('text')
675
+ .attr('x', ANCESTOR_DOT_R + 6)
676
+ .attr('y', ANCESTOR_LABEL_FONT_SIZE * 0.35)
677
+ .attr('fill', palette.textMuted)
678
+ .attr('font-size', ANCESTOR_LABEL_FONT_SIZE)
679
+ .text(ancestor.label);
680
+
681
+ // Hover effect
682
+ rowG
683
+ .on('mouseenter', function () {
684
+ d3Selection
685
+ .select(this)
686
+ .select('circle')
687
+ .attr('r', ANCESTOR_DOT_R + 1);
688
+ d3Selection.select(this).select('text').attr('fill', palette.text);
689
+ })
690
+ .on('mouseleave', function () {
691
+ d3Selection.select(this).select('circle').attr('r', ANCESTOR_DOT_R);
692
+ d3Selection
693
+ .select(this)
694
+ .select('text')
695
+ .attr('fill', palette.textMuted);
696
+ });
697
+ }
698
+ }
482
699
  }
483
700
 
484
701
  // Render legend — capsule pills.