@diagrammo/dgmo 0.8.22 → 0.8.23

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 (53) hide show
  1. package/dist/cli.cjs +111 -109
  2. package/dist/editor.cjs +3 -0
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +3 -0
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +3 -0
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +3 -0
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +1010 -215
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +97 -11
  13. package/dist/index.d.ts +97 -11
  14. package/dist/index.js +1001 -213
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal.cjs +380 -0
  17. package/dist/internal.cjs.map +1 -0
  18. package/dist/internal.d.cts +179 -0
  19. package/dist/internal.d.ts +179 -0
  20. package/dist/internal.js +337 -0
  21. package/dist/internal.js.map +1 -0
  22. package/docs/guide/chart-cycle.md +156 -0
  23. package/docs/guide/chart-journey-map.md +179 -0
  24. package/docs/guide/chart-pyramid.md +111 -0
  25. package/docs/guide/registry.json +5 -0
  26. package/docs/language-reference.md +62 -1
  27. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  28. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  29. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  30. package/package.json +11 -1
  31. package/src/cli.ts +5 -35
  32. package/src/completion.ts +9 -44
  33. package/src/cycle/layout.ts +19 -28
  34. package/src/cycle/renderer.ts +59 -32
  35. package/src/cycle/types.ts +21 -0
  36. package/src/d3.ts +21 -1
  37. package/src/dgmo-router.ts +73 -3
  38. package/src/echarts.ts +1 -1
  39. package/src/editor/keywords.ts +3 -0
  40. package/src/index.ts +13 -2
  41. package/src/infra/parser.ts +2 -2
  42. package/src/internal.ts +16 -0
  43. package/src/journey-map/renderer.ts +112 -47
  44. package/src/org/collapse.ts +81 -0
  45. package/src/org/renderer.ts +212 -4
  46. package/src/pyramid/parser.ts +172 -0
  47. package/src/pyramid/renderer.ts +684 -0
  48. package/src/pyramid/types.ts +28 -0
  49. package/src/render.ts +2 -8
  50. package/src/sequence/parser.ts +62 -20
  51. package/src/sequence/renderer.ts +2 -2
  52. package/src/tech-radar/interactive.ts +54 -0
  53. package/src/utils/parsing.ts +1 -0
@@ -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
  }
@@ -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
  // ============================================================
@@ -99,3 +115,68 @@ export function collapseOrgTree(
99
115
  hiddenCounts,
100
116
  };
101
117
  }
118
+
119
+ // ============================================================
120
+ // Focus (subtree drill-down)
121
+ // ============================================================
122
+
123
+ /** Find a node by ID and collect the ancestor path leading to it. */
124
+ function findNodeWithPath(
125
+ nodes: OrgNode[],
126
+ targetId: string,
127
+ path: AncestorInfo[]
128
+ ): { node: OrgNode; path: AncestorInfo[] } | null {
129
+ for (const node of nodes) {
130
+ if (node.id === targetId) {
131
+ return { node, path };
132
+ }
133
+ const result = findNodeWithPath(node.children, targetId, [
134
+ ...path,
135
+ {
136
+ id: node.id,
137
+ label: node.label,
138
+ lineNumber: node.lineNumber,
139
+ color: node.color,
140
+ metadata: { ...node.metadata },
141
+ isContainer: node.isContainer,
142
+ },
143
+ ]);
144
+ if (result) return result;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Extract a subtree rooted at `focusNodeId`, returning the focused tree
151
+ * and the ancestor breadcrumb path. Returns null if the node is not found.
152
+ */
153
+ export function focusOrgTree(
154
+ original: ParsedOrg,
155
+ focusNodeId: string
156
+ ): FocusOrgResult | null {
157
+ const found = findNodeWithPath(original.roots, focusNodeId, []);
158
+ if (!found) return null;
159
+
160
+ // If it's already a root, return as-is with empty ancestor path
161
+ const isRoot = original.roots.some((r) => r.id === focusNodeId);
162
+ if (isRoot) {
163
+ return {
164
+ parsed: {
165
+ ...original,
166
+ roots: [cloneNode(found.node)],
167
+ },
168
+ ancestorPath: [],
169
+ };
170
+ }
171
+
172
+ const cloned = cloneNode(found.node);
173
+ cloned.parentId = null;
174
+
175
+ return {
176
+ parsed: {
177
+ ...original,
178
+ roots: [cloned],
179
+ },
180
+ ancestorPath: found.path,
181
+ };
182
+ }
@@ -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,15 @@ 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 = !exportDims && ancestorPath && ancestorPath.length > 0;
142
+ const ancestorTrailHeight = hasAncestorTrail
143
+ ? ancestorPath.length * ANCESTOR_ROW_HEIGHT + ANCESTOR_TRAIL_BOTTOM_GAP
144
+ : 0;
145
+
131
146
  // Compute scale to fit diagram in viewport
132
147
  const diagramW = layout.width;
133
- let diagramH = layout.height + (fixedTitle ? 0 : titleOffset);
148
+ let diagramH = layout.height + (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
134
149
  if (fixedLegend) {
135
150
  // Remove the legend space from diagram height — legend is rendered separately
136
151
  diagramH -= layoutLegendShift;
@@ -200,10 +215,11 @@ export function renderOrg(
200
215
  }
201
216
  }
202
217
 
203
- // Content group (offset by title only when title is inside the scaled group)
218
+ // Content group (offset by title + ancestor trail height)
219
+ const contentYShift = (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
204
220
  const contentG = mainG
205
221
  .append('g')
206
- .attr('transform', `translate(0, ${fixedTitle ? 0 : titleOffset})`);
222
+ .attr('transform', `translate(0, ${contentYShift})`);
207
223
 
208
224
  // Build display name map from tag groups (lowercase key → original casing)
209
225
  const displayNames = new Map<string, string>();
@@ -211,6 +227,9 @@ export function renderOrg(
211
227
  displayNames.set(group.name.toLowerCase(), group.name);
212
228
  }
213
229
 
230
+ // Root node IDs — focus icon is suppressed on these (already the tree root)
231
+ const rootNodeIds = new Set(parsed.roots.map((r) => r.id));
232
+
214
233
  // Render container backgrounds (bottom layer)
215
234
  const colorOff = parsed.options?.color === 'off';
216
235
  for (const c of layout.containers) {
@@ -322,6 +341,45 @@ export function renderOrg(
322
341
  .attr('clip-path', `url(#${clipId})`)
323
342
  .attr('class', 'org-collapse-bar');
324
343
  }
344
+
345
+ // Focus icon (hover-reveal, interactive only) — for non-root containers with children
346
+ if (!exportDims && c.hasChildren && !rootNodeIds.has(c.nodeId)) {
347
+ const iconSize = 14;
348
+ const iconPad = 5;
349
+ const iconX = c.width - iconSize - iconPad;
350
+ const iconY = iconPad;
351
+
352
+ const focusG = cG
353
+ .append('g')
354
+ .attr('class', 'org-focus-icon')
355
+ .attr('data-focus-node', c.nodeId)
356
+ .attr('transform', `translate(${iconX}, ${iconY})`);
357
+
358
+ focusG
359
+ .append('rect')
360
+ .attr('x', -3)
361
+ .attr('y', -3)
362
+ .attr('width', iconSize + 6)
363
+ .attr('height', iconSize + 6)
364
+ .attr('fill', 'transparent');
365
+
366
+ const cx = iconSize / 2;
367
+ const cy = iconSize / 2;
368
+ focusG
369
+ .append('circle')
370
+ .attr('cx', cx)
371
+ .attr('cy', cy)
372
+ .attr('r', iconSize / 2 - 1)
373
+ .attr('fill', 'none')
374
+ .attr('stroke', palette.textMuted)
375
+ .attr('stroke-width', 1.5);
376
+ focusG
377
+ .append('circle')
378
+ .attr('cx', cx)
379
+ .attr('cy', cy)
380
+ .attr('r', 2)
381
+ .attr('fill', palette.textMuted);
382
+ }
325
383
  }
326
384
 
327
385
  // Render edges
@@ -479,6 +537,156 @@ export function renderOrg(
479
537
  .attr('clip-path', `url(#${clipId})`)
480
538
  .attr('class', 'org-collapse-bar');
481
539
  }
540
+
541
+ // Focus icon (hover-reveal, interactive only) — for non-root nodes with children
542
+ if (!exportDims && node.hasChildren && !rootNodeIds.has(node.id)) {
543
+ const iconSize = 14;
544
+ const iconPad = 5;
545
+ const iconX = node.width - iconSize - iconPad;
546
+ const iconY = iconPad;
547
+
548
+ const focusG = nodeG
549
+ .append('g')
550
+ .attr('class', 'org-focus-icon')
551
+ .attr('data-focus-node', node.id)
552
+ .attr('transform', `translate(${iconX}, ${iconY})`);
553
+
554
+ // Hit area
555
+ focusG
556
+ .append('rect')
557
+ .attr('x', -3)
558
+ .attr('y', -3)
559
+ .attr('width', iconSize + 6)
560
+ .attr('height', iconSize + 6)
561
+ .attr('fill', 'transparent');
562
+
563
+ // Scope/target icon: outer circle + inner dot
564
+ const cx = iconSize / 2;
565
+ const cy = iconSize / 2;
566
+ focusG
567
+ .append('circle')
568
+ .attr('cx', cx)
569
+ .attr('cy', cy)
570
+ .attr('r', iconSize / 2 - 1)
571
+ .attr('fill', 'none')
572
+ .attr('stroke', palette.textMuted)
573
+ .attr('stroke-width', 1.5);
574
+ focusG
575
+ .append('circle')
576
+ .attr('cx', cx)
577
+ .attr('cy', cy)
578
+ .attr('r', 2)
579
+ .attr('fill', palette.textMuted);
580
+ }
581
+ }
582
+
583
+ // Render ancestor breadcrumb trail (focus mode) — inside scaled group,
584
+ // centered on and connected to the root node
585
+ if (hasAncestorTrail) {
586
+ // Find the root node/container position in the layout
587
+ const rootNode = layout.nodes.find((n) => rootNodeIds.has(n.id));
588
+ const rootContainer = !rootNode
589
+ ? layout.containers.find((c) => rootNodeIds.has(c.nodeId))
590
+ : null;
591
+ // Nodes: x is center. Containers: x is left edge, so center = x + width/2
592
+ const rootCenterX = rootNode
593
+ ? rootNode.x
594
+ : rootContainer
595
+ ? rootContainer.x + rootContainer.width / 2
596
+ : null;
597
+ const rootTopY = rootNode ? rootNode.y : rootContainer ? rootContainer.y : null;
598
+ if (rootCenterX !== null && rootTopY !== null) {
599
+ // Trail connects directly to the top edge of the root node.
600
+ // The last ancestor dot sits ANCESTOR_TRAIL_BOTTOM_GAP above the root.
601
+ const trailBottomY = rootTopY - ANCESTOR_TRAIL_BOTTOM_GAP;
602
+
603
+ const trailG = contentG
604
+ .append('g')
605
+ .attr('class', 'org-ancestor-trail');
606
+
607
+ const count = ancestorPath!.length;
608
+
609
+ // Compute dot positions (top-down order, topmost ancestor highest)
610
+ const dotPositions: number[] = [];
611
+ for (let i = 0; i < count; i++) {
612
+ const fromBottom = count - 1 - i;
613
+ dotPositions.push(trailBottomY - fromBottom * ANCESTOR_ROW_HEIGHT);
614
+ }
615
+
616
+ // Single continuous line from topmost dot to root node top edge
617
+ const lineTopY = dotPositions[0];
618
+ trailG
619
+ .append('line')
620
+ .attr('x1', rootCenterX)
621
+ .attr('y1', lineTopY)
622
+ .attr('x2', rootCenterX)
623
+ .attr('y2', rootTopY)
624
+ .attr('stroke', palette.textMuted)
625
+ .attr('stroke-width', 1.5)
626
+ .attr('stroke-opacity', 0.4);
627
+
628
+ // Dots and labels on top of the line
629
+ for (let i = 0; i < count; i++) {
630
+ const ancestor = ancestorPath![i];
631
+ const dotY = dotPositions[i];
632
+
633
+ // Resolve color from tag groups (same logic as node cards)
634
+ const resolvedColor = ancestor.color
635
+ ?? resolveTagColor(
636
+ ancestor.metadata,
637
+ parsed.tagGroups,
638
+ activeTagGroup ?? null,
639
+ ancestor.isContainer
640
+ );
641
+ const dotColor = resolvedColor ?? palette.textMuted;
642
+
643
+ const rowG = trailG
644
+ .append('g')
645
+ .attr('class', 'org-ancestor-node')
646
+ .attr('data-focus-ancestor', ancestor.id)
647
+ .style('cursor', 'pointer')
648
+ .attr('transform', `translate(${rootCenterX}, ${dotY})`);
649
+
650
+ // Hit area
651
+ rowG
652
+ .append('rect')
653
+ .attr('x', -ANCESTOR_DOT_R - 2)
654
+ .attr('y', -ANCESTOR_DOT_R - 2)
655
+ .attr('width', 120)
656
+ .attr('height', ANCESTOR_DOT_R * 2 + 4)
657
+ .attr('fill', 'transparent');
658
+
659
+ // Dot — colored by tag group value
660
+ rowG
661
+ .append('circle')
662
+ .attr('cx', 0)
663
+ .attr('cy', 0)
664
+ .attr('r', ANCESTOR_DOT_R)
665
+ .attr('fill', dotColor);
666
+
667
+ // Label
668
+ rowG
669
+ .append('text')
670
+ .attr('x', ANCESTOR_DOT_R + 6)
671
+ .attr('y', ANCESTOR_LABEL_FONT_SIZE * 0.35)
672
+ .attr('fill', palette.textMuted)
673
+ .attr('font-size', ANCESTOR_LABEL_FONT_SIZE)
674
+ .text(ancestor.label);
675
+
676
+ // Hover effect
677
+ rowG
678
+ .on('mouseenter', function () {
679
+ d3Selection.select(this).select('circle')
680
+ .attr('r', ANCESTOR_DOT_R + 1);
681
+ d3Selection.select(this).select('text').attr('fill', palette.text);
682
+ })
683
+ .on('mouseleave', function () {
684
+ d3Selection.select(this).select('circle')
685
+ .attr('r', ANCESTOR_DOT_R);
686
+ d3Selection.select(this).select('text').attr('fill', palette.textMuted);
687
+ });
688
+ }
689
+ }
482
690
  }
483
691
 
484
692
  // Render legend — capsule pills.