@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.
- package/.claude/commands/dgmo.md +60 -72
- package/dist/cli.cjs +123 -116
- package/dist/editor.cjs +3 -2
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +3 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +3 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +1649 -442
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -23
- package/dist/index.d.ts +196 -23
- package/dist/index.js +1631 -440
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +677 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +267 -0
- package/dist/internal.d.ts +267 -0
- package/dist/internal.js +633 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-area.md +17 -17
- package/docs/guide/chart-bar-stacked.md +12 -12
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-doughnut.md +10 -10
- package/docs/guide/chart-funnel.md +9 -9
- package/docs/guide/chart-heatmap.md +10 -10
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-kanban.md +2 -0
- package/docs/guide/chart-line.md +19 -19
- package/docs/guide/chart-multi-line.md +16 -16
- package/docs/guide/chart-pie.md +11 -11
- package/docs/guide/chart-polar-area.md +10 -10
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-radar.md +9 -9
- package/docs/guide/chart-scatter.md +24 -27
- package/docs/guide/index.md +3 -3
- package/docs/guide/registry.json +5 -0
- package/docs/language-reference.md +108 -26
- package/fonts/Inter-Bold.ttf +0 -0
- package/fonts/Inter-Regular.ttf +0 -0
- package/fonts/LICENSE-Inter.txt +92 -0
- package/gallery/fixtures/bar-stacked.dgmo +12 -6
- package/gallery/fixtures/heatmap.dgmo +12 -6
- package/gallery/fixtures/multi-line.dgmo +11 -7
- package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- package/gallery/fixtures/quadrant.dgmo +8 -8
- package/gallery/fixtures/scatter.dgmo +12 -12
- package/package.json +14 -2
- package/src/boxes-and-lines/parser.ts +13 -2
- package/src/boxes-and-lines/renderer.ts +22 -13
- package/src/chart-type-scoring.ts +162 -0
- package/src/chart-types.ts +437 -0
- package/src/cli.ts +152 -101
- package/src/completion.ts +9 -48
- package/src/cycle/layout.ts +19 -28
- package/src/cycle/renderer.ts +59 -32
- package/src/cycle/types.ts +21 -0
- package/src/d3.ts +30 -3
- package/src/dgmo-router.ts +98 -73
- package/src/echarts.ts +1 -1
- package/src/editor/keywords.ts +3 -2
- package/src/fonts.ts +3 -2
- package/src/gantt/parser.ts +5 -1
- package/src/index.ts +37 -3
- package/src/infra/parser.ts +3 -3
- package/src/internal.ts +20 -0
- package/src/journey-map/layout.ts +7 -3
- package/src/journey-map/parser.ts +5 -1
- package/src/journey-map/renderer.ts +112 -47
- package/src/kanban/parser.ts +5 -1
- package/src/org/collapse.ts +82 -4
- package/src/org/parser.ts +1 -1
- package/src/org/renderer.ts +221 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +64 -22
- package/src/sequence/participant-inference.ts +0 -1
- package/src/sequence/renderer.ts +97 -265
- package/src/sharing.ts +0 -1
- package/src/sitemap/parser.ts +1 -1
- package/src/tech-radar/interactive.ts +54 -0
- package/src/utils/parsing.ts +1 -0
- package/src/utils/tag-groups.ts +35 -5
- 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(
|
|
105
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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 *
|
|
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,
|
|
1330
|
-
const charWidth =
|
|
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 /
|
|
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
|
}
|
package/src/kanban/parser.ts
CHANGED
|
@@ -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
|
}
|
package/src/org/collapse.ts
CHANGED
|
@@ -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 (
|
package/src/org/renderer.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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, ${
|
|
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.
|