@diagrammo/dgmo 0.37.0 → 0.38.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 +1 -0
- package/.github/copilot-instructions.md +1 -0
- package/.windsurfrules +1 -0
- package/SKILL.md +1 -0
- package/dist/advanced.cjs +919 -81
- package/dist/advanced.d.cts +110 -1
- package/dist/advanced.d.ts +110 -1
- package/dist/advanced.js +916 -79
- package/dist/auto.cjs +908 -78
- package/dist/auto.js +96 -95
- package/dist/auto.mjs +909 -76
- package/dist/cli.cjs +117 -116
- package/dist/editor.cjs +4 -1
- package/dist/editor.js +4 -1
- package/dist/highlight.cjs +3 -0
- package/dist/highlight.js +3 -0
- package/dist/index.cjs +904 -77
- package/dist/index.js +905 -75
- package/docs/ai-integration.md +1 -0
- package/docs/language-reference.md +95 -0
- package/gallery/fixtures/treemap-branch.dgmo +15 -0
- package/gallery/fixtures/treemap-heat.dgmo +12 -0
- package/gallery/fixtures/treemap-tag-heat.dgmo +21 -0
- package/gallery/fixtures/treemap-tags.dgmo +18 -0
- package/package.json +1 -1
- package/src/advanced.ts +11 -0
- package/src/boxes-and-lines/layout-search.ts +19 -1
- package/src/chart-type-registry.ts +4 -0
- package/src/chart-types.ts +5 -0
- package/src/completion.ts +64 -0
- package/src/d3.ts +30 -18
- package/src/editor/highlight-api.ts +2 -0
- package/src/editor/keywords.ts +3 -0
- package/src/map/dimensions.ts +19 -2
- package/src/treemap/layout.ts +189 -0
- package/src/treemap/parser.ts +475 -0
- package/src/treemap/renderer.ts +698 -0
- package/src/treemap/types.ts +57 -0
- package/src/utils/parsing.ts +1 -0
- package/src/utils/reserved-key-registry.ts +5 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Treemap — D3 SVG Renderer
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Emits a static, full-tree treemap. Interactive chrome (drill breadcrumb,
|
|
6
|
+
// tooltips, the runtime color-mode switcher) lives app-side; the only
|
|
7
|
+
// interactive affordance drawn here is the scope/target focus icon, marked
|
|
8
|
+
// `data-export-ignore` so `finalizeSvgExport` strips it on export.
|
|
9
|
+
|
|
10
|
+
import * as d3Selection from 'd3-selection';
|
|
11
|
+
import { scaleLinear } from 'd3-scale';
|
|
12
|
+
import { FONT_FAMILY } from '../fonts';
|
|
13
|
+
import { resolveColor } from '../colors';
|
|
14
|
+
import { contrastText, getSeriesColors, mix } from '../palettes/color-utils';
|
|
15
|
+
import { resolveTagColor, tagAttrKey } from '../utils/tag-groups';
|
|
16
|
+
import { measureText } from '../utils/text-measure';
|
|
17
|
+
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
18
|
+
import { getMaxLegendReservedHeight } from '../utils/legend-layout';
|
|
19
|
+
import { LEGEND_GROUP_GAP } from '../utils/legend-constants';
|
|
20
|
+
import type { LegendGroupData, LegendPosition } from '../utils/legend-types';
|
|
21
|
+
import {
|
|
22
|
+
TITLE_FONT_SIZE,
|
|
23
|
+
TITLE_FONT_WEIGHT,
|
|
24
|
+
TITLE_Y,
|
|
25
|
+
} from '../utils/title-constants';
|
|
26
|
+
import type { PaletteColors } from '../palettes';
|
|
27
|
+
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
28
|
+
import type { ParsedTreemap, TreemapColorMode, TreemapNode } from './types';
|
|
29
|
+
import { layoutTreemap, type TreemapCell } from './layout';
|
|
30
|
+
|
|
31
|
+
const PADDING = 12;
|
|
32
|
+
const HEADER_H = 18;
|
|
33
|
+
const TITLE_BAND = 36;
|
|
34
|
+
const MUTED_FILL = '#cbd5e1';
|
|
35
|
+
/** Percent of a leaf's own color kept when muting it toward the background. */
|
|
36
|
+
const LEAF_MUTE_PCT = 50;
|
|
37
|
+
|
|
38
|
+
/** Standard placement every dgmo legend uses: centered, under the title. */
|
|
39
|
+
const LEGEND_POSITION: LegendPosition = {
|
|
40
|
+
placement: 'top-center',
|
|
41
|
+
titleRelation: 'below-title',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface TreemapRenderOptions {
|
|
45
|
+
/** Color mode override (app's runtime switcher). Defaults to source. */
|
|
46
|
+
colorMode?: TreemapColorMode;
|
|
47
|
+
/** Render budget (interactive only). Export omits it → full tree. */
|
|
48
|
+
maxDepth?: number;
|
|
49
|
+
/** Shift the branch-hue index so a drilled-into view keeps the color it had
|
|
50
|
+
* when expanded (the re-rooted node would otherwise become index 0 = the
|
|
51
|
+
* first hue). Set to the drilled branch's original top-level index. */
|
|
52
|
+
colorOffset?: number;
|
|
53
|
+
/** Click handler for drillable cells (app interactivity). */
|
|
54
|
+
onClickItem?: (lineNumber: number) => void;
|
|
55
|
+
/** Color-mode switch fired when a legend pill is clicked (app interactivity).
|
|
56
|
+
* The mode switcher is baked into the legend (clickable group pills), so the
|
|
57
|
+
* app no longer renders a separate overlay control. */
|
|
58
|
+
onSelectMode?: (mode: TreemapColorMode) => void;
|
|
59
|
+
exportMode?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Render for CLI/export (full tree, no drill chrome). */
|
|
63
|
+
export function renderTreemapForExport(
|
|
64
|
+
container: HTMLDivElement,
|
|
65
|
+
parsed: ParsedTreemap,
|
|
66
|
+
palette: PaletteColors,
|
|
67
|
+
isDark: boolean,
|
|
68
|
+
exportDims?: D3ExportDimensions,
|
|
69
|
+
options?: TreemapRenderOptions
|
|
70
|
+
): void {
|
|
71
|
+
renderTreemap(container, parsed, palette, isDark, exportDims, {
|
|
72
|
+
exportMode: true,
|
|
73
|
+
...options,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderTreemap(
|
|
78
|
+
container: HTMLDivElement,
|
|
79
|
+
parsed: ParsedTreemap,
|
|
80
|
+
palette: PaletteColors,
|
|
81
|
+
isDark: boolean,
|
|
82
|
+
exportDims?: D3ExportDimensions,
|
|
83
|
+
options: TreemapRenderOptions = {}
|
|
84
|
+
): void {
|
|
85
|
+
if (parsed.error || parsed.roots.length === 0) return;
|
|
86
|
+
void isDark; // colors derive from the palette; kept for handler-signature parity.
|
|
87
|
+
|
|
88
|
+
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
89
|
+
const width = exportDims?.width ?? container.clientWidth;
|
|
90
|
+
const height = exportDims?.height ?? container.clientHeight;
|
|
91
|
+
if (width <= 0 || height <= 0) return;
|
|
92
|
+
|
|
93
|
+
const mode = resolveColorMode(parsed, options.colorMode);
|
|
94
|
+
const opts = parsed.options;
|
|
95
|
+
const exportMode = options.exportMode ?? false;
|
|
96
|
+
const seriesColorsTop = getSeriesColors(palette);
|
|
97
|
+
const showTitle = !!parsed.title;
|
|
98
|
+
const titleH = showTitle ? TITLE_BAND : 0;
|
|
99
|
+
|
|
100
|
+
const heat = buildHeatScale(parsed, palette);
|
|
101
|
+
const legend = !opts.noLegend
|
|
102
|
+
? buildLegend(mode, parsed, heat, seriesColorsTop, options.colorOffset ?? 0)
|
|
103
|
+
: null;
|
|
104
|
+
|
|
105
|
+
// Reserve a band below the title for the standardized legend, exactly like
|
|
106
|
+
// mindmap/org/map (top-center, below-title). The treemap fills the rest.
|
|
107
|
+
const legendReserve = legend
|
|
108
|
+
? getMaxLegendReservedHeight(
|
|
109
|
+
{ groups: legend.groups, position: LEGEND_POSITION, mode: 'preview' },
|
|
110
|
+
width
|
|
111
|
+
) + LEGEND_GROUP_GAP
|
|
112
|
+
: 0;
|
|
113
|
+
|
|
114
|
+
const headerH = opts.noHeaders ? 0 : HEADER_H;
|
|
115
|
+
const areaX = PADDING;
|
|
116
|
+
const areaY = titleH + legendReserve + PADDING;
|
|
117
|
+
const areaW = Math.max(1, width - PADDING * 2);
|
|
118
|
+
const areaH = Math.max(1, height - areaY - PADDING);
|
|
119
|
+
|
|
120
|
+
const layout = layoutTreemap(parsed.roots, {
|
|
121
|
+
width: areaW,
|
|
122
|
+
height: areaH,
|
|
123
|
+
headerH,
|
|
124
|
+
...(options.maxDepth !== undefined && { maxDepth: options.maxDepth }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── SVG root ───────────────────────────────────────────────
|
|
128
|
+
const svg = d3Selection
|
|
129
|
+
.select(container)
|
|
130
|
+
.append('svg')
|
|
131
|
+
.attr('width', width)
|
|
132
|
+
.attr('height', height)
|
|
133
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
134
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
135
|
+
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
136
|
+
.attr('class', 'dgmo-treemap')
|
|
137
|
+
.style('font-family', FONT_FAMILY);
|
|
138
|
+
|
|
139
|
+
// Drill icon: faint by default, a touch clearer when its cell is hovered.
|
|
140
|
+
svg
|
|
141
|
+
.append('style')
|
|
142
|
+
.text(
|
|
143
|
+
'.dgmo-treemap-focus{opacity:.3;transition:opacity .12s}' +
|
|
144
|
+
'.dgmo-treemap-cell:hover .dgmo-treemap-focus{opacity:.7}'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
svg
|
|
148
|
+
.append('rect')
|
|
149
|
+
.attr('width', width)
|
|
150
|
+
.attr('height', height)
|
|
151
|
+
.attr('fill', palette.bg);
|
|
152
|
+
|
|
153
|
+
if (showTitle) {
|
|
154
|
+
const title = svg
|
|
155
|
+
.append('text')
|
|
156
|
+
.attr('class', 'chart-title')
|
|
157
|
+
.attr('x', width / 2)
|
|
158
|
+
.attr('y', TITLE_Y)
|
|
159
|
+
.attr('text-anchor', 'middle')
|
|
160
|
+
.attr('fill', palette.text)
|
|
161
|
+
.attr('font-family', FONT_FAMILY)
|
|
162
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
163
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
164
|
+
.text(parsed.title);
|
|
165
|
+
if (parsed.titleLineNumber !== null) {
|
|
166
|
+
title.attr('data-line-number', parsed.titleLineNumber);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const root = svg
|
|
171
|
+
.append('g')
|
|
172
|
+
.attr('class', 'dgmo-treemap-cells')
|
|
173
|
+
.attr('transform', `translate(${areaX},${areaY})`);
|
|
174
|
+
|
|
175
|
+
const seriesColors = seriesColorsTop;
|
|
176
|
+
const colorOffset = options.colorOffset ?? 0;
|
|
177
|
+
const activeGroup =
|
|
178
|
+
parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null;
|
|
179
|
+
|
|
180
|
+
// Branch hue is keyed off the top-level branch's SOURCE order (not d3's
|
|
181
|
+
// value-sorted order) so the cell colors match the legend, which lists roots
|
|
182
|
+
// in source order. `colorOffset` shifts the index so a drilled-into branch
|
|
183
|
+
// keeps the hue it had at the top level.
|
|
184
|
+
const rootIndexByLabel = new Map(parsed.roots.map((r, i) => [r.label, i]));
|
|
185
|
+
|
|
186
|
+
const colorOf = (cell: TreemapCell): string => {
|
|
187
|
+
if (mode === 'heat') {
|
|
188
|
+
return cell.heat !== undefined && heat
|
|
189
|
+
? heat.scale(cell.heat)
|
|
190
|
+
: MUTED_FILL;
|
|
191
|
+
}
|
|
192
|
+
if (mode === 'tag') {
|
|
193
|
+
if (!cell.node) return MUTED_FILL;
|
|
194
|
+
return (
|
|
195
|
+
resolveTagColor(cell.node.metadata, parsed.tagGroups, activeGroup) ??
|
|
196
|
+
MUTED_FILL
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
// branch: top-level hue, lightened slightly with depth. `mix` takes a
|
|
200
|
+
// PERCENTAGE of the first color to keep (0–100), so deeper cells retain less
|
|
201
|
+
// hue. Floor at 55% so leaves stay clearly saturated, not washed out.
|
|
202
|
+
const topLabel = cell.path[0] ?? cell.label;
|
|
203
|
+
const idx = (rootIndexByLabel.get(topLabel) ?? cell.topIndex) + colorOffset;
|
|
204
|
+
const hue = seriesColors[idx % seriesColors.length]!;
|
|
205
|
+
if (cell.depth <= 1) return hue;
|
|
206
|
+
const keepPct = Math.max(55, 100 - (cell.depth - 1) * 18);
|
|
207
|
+
return mix(hue, palette.bg, keepPct);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ── Cells ──────────────────────────────────────────────────
|
|
211
|
+
for (const cell of layout.cells) {
|
|
212
|
+
const w = Math.max(0, cell.x1 - cell.x0);
|
|
213
|
+
const h = Math.max(0, cell.y1 - cell.y0);
|
|
214
|
+
if (w <= 0 || h <= 0) continue;
|
|
215
|
+
|
|
216
|
+
// Emphasis: the containing box (header band) is pure color; the internal
|
|
217
|
+
// leaf cells are muted (mixed toward the background). Both are opaque so the
|
|
218
|
+
// pure container behind a leaf doesn't bleed through.
|
|
219
|
+
const baseColor = colorOf(cell);
|
|
220
|
+
const fill = cell.isContainer
|
|
221
|
+
? baseColor
|
|
222
|
+
: mix(baseColor, palette.bg, LEAF_MUTE_PCT);
|
|
223
|
+
const drillable = cell.isContainer || cell.isCollapsed;
|
|
224
|
+
|
|
225
|
+
const g = root
|
|
226
|
+
.append('g')
|
|
227
|
+
.attr('class', 'dgmo-treemap-cell')
|
|
228
|
+
.attr('transform', `translate(${cell.x0},${cell.y0})`)
|
|
229
|
+
.attr('data-node-path', cell.path.join(' / '));
|
|
230
|
+
if (cell.lineNumber !== undefined) {
|
|
231
|
+
g.attr('data-line-number', cell.lineNumber);
|
|
232
|
+
}
|
|
233
|
+
if (cell.heat !== undefined) g.attr('data-heat', cell.heat);
|
|
234
|
+
if (cell.node && activeGroup) {
|
|
235
|
+
const key = tagAttrKey(activeGroup);
|
|
236
|
+
const tagVal = cell.node.metadata[key];
|
|
237
|
+
if (tagVal) g.attr(`data-tag-${key}`, tagVal);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rect = g
|
|
241
|
+
.append('rect')
|
|
242
|
+
.attr(
|
|
243
|
+
'class',
|
|
244
|
+
cell.isContainer ? 'dgmo-treemap-rect branch' : 'dgmo-treemap-rect'
|
|
245
|
+
)
|
|
246
|
+
.attr('width', w)
|
|
247
|
+
.attr('height', h)
|
|
248
|
+
.attr('rx', 2)
|
|
249
|
+
.attr('fill', fill)
|
|
250
|
+
.attr('fill-opacity', 1)
|
|
251
|
+
.attr('stroke', palette.bg)
|
|
252
|
+
// No stroke on internal leaf shapes — the paddingInner gap already
|
|
253
|
+
// separates them; only the containing box keeps a frame.
|
|
254
|
+
.attr('stroke-width', cell.isContainer ? 1 : 0);
|
|
255
|
+
|
|
256
|
+
if (drillable && options.onClickItem && cell.lineNumber !== undefined) {
|
|
257
|
+
const ln = cell.lineNumber;
|
|
258
|
+
rect
|
|
259
|
+
.style('cursor', 'pointer')
|
|
260
|
+
.on('click', () => options.onClickItem!(ln));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ink = contrastText(
|
|
264
|
+
fill,
|
|
265
|
+
palette.textOnFillLight,
|
|
266
|
+
palette.textOnFillDark
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Header bar: group name (left) + its aggregate value/% (right) so a
|
|
270
|
+
// container's share is readable without drilling.
|
|
271
|
+
if (cell.isContainer && headerH > 0 && w > 34 && h > headerH) {
|
|
272
|
+
const valParts: string[] = [];
|
|
273
|
+
if (!opts.noValues) valParts.push(compactNumber(cell.value));
|
|
274
|
+
if (!opts.noPercent) valParts.push(formatPct(cell.pctOfRoot));
|
|
275
|
+
const valStr = valParts.join(' · ');
|
|
276
|
+
// Only reserve space for the focus icon when one is actually drawn (a
|
|
277
|
+
// non-drillable container's header value sits flush right).
|
|
278
|
+
const ICON_RESERVE = drillable ? 16 : 4;
|
|
279
|
+
const showVal = valStr.length > 0 && w > 110;
|
|
280
|
+
const valW = showVal ? measureText(valStr, 10.5) : 0;
|
|
281
|
+
const labelMax = w - 8 - ICON_RESERVE - (showVal ? valW + 10 : 0);
|
|
282
|
+
|
|
283
|
+
g.append('text')
|
|
284
|
+
.attr('class', 'dgmo-treemap-header')
|
|
285
|
+
.attr('x', 6)
|
|
286
|
+
.attr('y', 13)
|
|
287
|
+
.attr('font-size', 11)
|
|
288
|
+
.attr('font-weight', 700)
|
|
289
|
+
.attr('fill', ink)
|
|
290
|
+
.text(clipLabel(cell.label, Math.max(0, labelMax), 11));
|
|
291
|
+
|
|
292
|
+
if (showVal) {
|
|
293
|
+
g.append('text')
|
|
294
|
+
.attr('class', 'dgmo-treemap-header-value')
|
|
295
|
+
.attr('x', w - ICON_RESERVE - 4)
|
|
296
|
+
.attr('y', 13)
|
|
297
|
+
.attr('text-anchor', 'end')
|
|
298
|
+
.attr('font-size', 10.5)
|
|
299
|
+
.attr('fill', ink)
|
|
300
|
+
.attr('opacity', 0.75)
|
|
301
|
+
.text(valStr);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Leaf / collapsed-block labels. The name scales with the cell so a big
|
|
306
|
+
// shape fills its space; the bigger the label, the more it's muted — a soft
|
|
307
|
+
// watermark on large cells, crisp on small ones. Fit-to-width so the name
|
|
308
|
+
// never has to truncate just because it scaled up.
|
|
309
|
+
// Pad scales down on small cells so a tight label (e.g. a rolled-up
|
|
310
|
+
// "Trivia" leaf) still fits instead of truncating; big cells keep the roomy
|
|
311
|
+
// 14px gutter. MIN_FS lets the name shrink well below the comfortable 12px
|
|
312
|
+
// floor on cramped cells — readability yields to showing the whole word.
|
|
313
|
+
const PAD = Math.max(4, Math.min(14, Math.floor(Math.min(w, h) / 5)));
|
|
314
|
+
const MIN_FS = 7;
|
|
315
|
+
if (!cell.isContainer && w >= 2 * PAD + 8 && h >= 2 * PAD + 8) {
|
|
316
|
+
const maxW = w - 2 * PAD;
|
|
317
|
+
let fs = clamp(Math.round(Math.min(w / 3.6, h / 3.4)), MIN_FS, 72);
|
|
318
|
+
// Safety factor: text-measure under-estimates bold glyph widths, so shrink
|
|
319
|
+
// a touch more to guarantee the PAD gap from the right edge.
|
|
320
|
+
const nameW = measureText(cell.label, fs) * 1.06;
|
|
321
|
+
if (nameW > maxW) fs = Math.max(MIN_FS, Math.floor((fs * maxW) / nameW));
|
|
322
|
+
const tg = clamp((fs - MIN_FS) / (72 - MIN_FS), 0, 1);
|
|
323
|
+
const nameOpacity = 0.95 - tg * 0.5; // 0.95 small → 0.45 large
|
|
324
|
+
const vfs = clamp(Math.round(fs * 0.42), 11, 28);
|
|
325
|
+
const valOpacity = Math.max(0.45, nameOpacity - 0.1);
|
|
326
|
+
|
|
327
|
+
let y = PAD + Math.round(fs * 0.78); // top PAD above the cap height
|
|
328
|
+
g.append('text')
|
|
329
|
+
.attr('class', 'dgmo-treemap-label')
|
|
330
|
+
.attr('x', PAD)
|
|
331
|
+
.attr('y', y)
|
|
332
|
+
.attr('font-size', fs)
|
|
333
|
+
.attr('font-weight', 600)
|
|
334
|
+
.attr('fill', ink)
|
|
335
|
+
.attr('opacity', nameOpacity)
|
|
336
|
+
.text(clipLabel(cell.label, maxW, fs));
|
|
337
|
+
|
|
338
|
+
const valParts = [
|
|
339
|
+
opts.noValues ? '' : compactNumber(cell.value),
|
|
340
|
+
opts.noPercent ? '' : formatPct(cell.pctOfRoot),
|
|
341
|
+
].filter(Boolean);
|
|
342
|
+
const combined = valParts.join(' · ');
|
|
343
|
+
const nameBaseY = y;
|
|
344
|
+
const addLineAt = (
|
|
345
|
+
text: string,
|
|
346
|
+
yy: number,
|
|
347
|
+
size: number,
|
|
348
|
+
opacity: number
|
|
349
|
+
): void => {
|
|
350
|
+
g.append('text')
|
|
351
|
+
.attr('class', 'dgmo-treemap-value')
|
|
352
|
+
.attr('x', PAD)
|
|
353
|
+
.attr('y', yy)
|
|
354
|
+
.attr('font-size', size)
|
|
355
|
+
.attr('fill', ink)
|
|
356
|
+
.attr('opacity', opacity)
|
|
357
|
+
.text(text);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (combined) {
|
|
361
|
+
const fitsW = measureText(combined, vfs) <= maxW;
|
|
362
|
+
const roomBelow = h - PAD > y + vfs + 6;
|
|
363
|
+
if (fitsW && roomBelow) {
|
|
364
|
+
// Preferred: value · % on its own line under the name.
|
|
365
|
+
addLineAt(combined, y + vfs + 6, vfs, valOpacity);
|
|
366
|
+
} else if (fitsW) {
|
|
367
|
+
// Short cell: no room below → trail the value after the name,
|
|
368
|
+
// smaller, on the same line (if it fits the width).
|
|
369
|
+
const ivfs = clamp(Math.round(fs * 0.5), 11, Math.max(11, vfs));
|
|
370
|
+
const rendered = clipLabel(cell.label, maxW, fs);
|
|
371
|
+
// Safety factor: text-measure under-estimates bold glyph width, so pad
|
|
372
|
+
// it to the worst case (and add a real gap) or the value collides with
|
|
373
|
+
// the name's end (e.g. "Hardtack70").
|
|
374
|
+
const nW = measureText(rendered, fs) * 1.12;
|
|
375
|
+
const gap = Math.max(12, Math.round(fs * 0.25));
|
|
376
|
+
if (PAD + nW + gap + measureText(combined, ivfs) <= w - PAD) {
|
|
377
|
+
g.append('text')
|
|
378
|
+
.attr('class', 'dgmo-treemap-value')
|
|
379
|
+
.attr('x', PAD + nW + gap)
|
|
380
|
+
.attr('y', nameBaseY)
|
|
381
|
+
.attr('font-size', ivfs)
|
|
382
|
+
.attr('fill', ink)
|
|
383
|
+
.attr('opacity', valOpacity)
|
|
384
|
+
.text(combined);
|
|
385
|
+
}
|
|
386
|
+
} else if (valParts.length === 2) {
|
|
387
|
+
// Tall-narrow cell: combined too wide → stack value then % below.
|
|
388
|
+
if (h - PAD > y + vfs + 6) {
|
|
389
|
+
y += vfs + 6;
|
|
390
|
+
addLineAt(valParts[0]!, y, vfs, valOpacity);
|
|
391
|
+
}
|
|
392
|
+
const pfs = Math.max(10, Math.round(vfs * 0.9));
|
|
393
|
+
if (h - PAD > y + pfs + 4) {
|
|
394
|
+
y += pfs + 4;
|
|
395
|
+
addLineAt(valParts[1]!, y, pfs, valOpacity);
|
|
396
|
+
}
|
|
397
|
+
} else if (h - PAD > y + vfs + 6) {
|
|
398
|
+
addLineAt(combined, y + vfs + 6, vfs, valOpacity);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Scope/target focus icon on drillable cells (interactive-only).
|
|
404
|
+
if (drillable && w > 30 && h > 22) {
|
|
405
|
+
drawFocusIcon(g, w, ink, 9);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (legend) {
|
|
410
|
+
const legendG = svg
|
|
411
|
+
.append('g')
|
|
412
|
+
.attr('class', 'dgmo-treemap-legend')
|
|
413
|
+
.attr('transform', `translate(0, ${titleH})`);
|
|
414
|
+
renderIntegratedLegend(legendG, {
|
|
415
|
+
groups: legend.groups,
|
|
416
|
+
palette: {
|
|
417
|
+
bg: palette.bg,
|
|
418
|
+
surface: palette.surface,
|
|
419
|
+
text: palette.text,
|
|
420
|
+
textMuted: palette.textMuted,
|
|
421
|
+
primary: palette.primary,
|
|
422
|
+
},
|
|
423
|
+
isDark,
|
|
424
|
+
width,
|
|
425
|
+
mode: exportMode ? 'export' : 'preview',
|
|
426
|
+
position: LEGEND_POSITION,
|
|
427
|
+
activeGroup: legend.activeGroup,
|
|
428
|
+
// The color-mode switcher is the legend itself: each applicable mode is a
|
|
429
|
+
// group pill; the active one is the open capsule, the rest are clickable
|
|
430
|
+
// pills that switch mode. Export shows only the active group.
|
|
431
|
+
showInactivePills: !exportMode,
|
|
432
|
+
showEmptyGroups: !exportMode,
|
|
433
|
+
...(options.onSelectMode !== undefined && {
|
|
434
|
+
callbacks: {
|
|
435
|
+
onGroupToggle: (name: string) => {
|
|
436
|
+
const m = legend.modeByName.get(name);
|
|
437
|
+
if (m) options.onSelectMode!(m);
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============================================================
|
|
446
|
+
// Legend group assembly — reuse the standard tag-group / gradient framework.
|
|
447
|
+
// ============================================================
|
|
448
|
+
|
|
449
|
+
interface TreemapLegend {
|
|
450
|
+
groups: LegendGroupData[];
|
|
451
|
+
activeGroup: string | null;
|
|
452
|
+
/** Legend group name → the color mode it selects (for the click callback). */
|
|
453
|
+
modeByName: Map<string, TreemapColorMode>;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build one legend group per APPLICABLE color mode (tag if tags exist, heat if
|
|
458
|
+
* heat data exists, branch always). The active mode's group renders as the open
|
|
459
|
+
* capsule; the others render as clickable pills that switch mode — i.e. the
|
|
460
|
+
* mode switcher IS the legend (the active-group pattern used elsewhere).
|
|
461
|
+
*/
|
|
462
|
+
function buildLegend(
|
|
463
|
+
activeMode: TreemapColorMode,
|
|
464
|
+
parsed: ParsedTreemap,
|
|
465
|
+
heat: HeatScale | null,
|
|
466
|
+
seriesColors: string[],
|
|
467
|
+
colorOffset: number
|
|
468
|
+
): TreemapLegend {
|
|
469
|
+
const groups: LegendGroupData[] = [];
|
|
470
|
+
const modeByName = new Map<string, TreemapColorMode>();
|
|
471
|
+
let activeGroup: string | null = null;
|
|
472
|
+
|
|
473
|
+
// ── Tag ──────────────────────────────────────────────────
|
|
474
|
+
if (parsed.tagGroups.length > 0) {
|
|
475
|
+
const tg = parsed.tagGroups[0]!;
|
|
476
|
+
const used = new Set<string>();
|
|
477
|
+
const collect = (nodes: readonly TreemapNode[]): void => {
|
|
478
|
+
for (const n of nodes) {
|
|
479
|
+
const v = n.metadata[tagAttrKey(tg.name)];
|
|
480
|
+
if (v) used.add(v.toLowerCase());
|
|
481
|
+
collect(n.children);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
collect(parsed.roots);
|
|
485
|
+
groups.push({
|
|
486
|
+
name: tg.name,
|
|
487
|
+
entries: tg.entries
|
|
488
|
+
.filter((e) => used.has(e.value.toLowerCase()))
|
|
489
|
+
.map((e) => ({ value: e.value, color: e.color })),
|
|
490
|
+
});
|
|
491
|
+
modeByName.set(tg.name, 'tag');
|
|
492
|
+
if (activeMode === 'tag') activeGroup = tg.name;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── Heat ─────────────────────────────────────────────────
|
|
496
|
+
if (heat) {
|
|
497
|
+
const name = parsed.options.heatLabel ?? 'Value';
|
|
498
|
+
groups.push({
|
|
499
|
+
name,
|
|
500
|
+
entries: [],
|
|
501
|
+
gradient: {
|
|
502
|
+
min: heat.min,
|
|
503
|
+
max: heat.max,
|
|
504
|
+
low: heat.stops[0]!,
|
|
505
|
+
high: heat.stops[heat.stops.length - 1]!,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
modeByName.set(name, 'heat');
|
|
509
|
+
if (activeMode === 'heat') activeGroup = name;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Branch (always) ──────────────────────────────────────
|
|
513
|
+
groups.push({
|
|
514
|
+
name: 'Branch',
|
|
515
|
+
entries: parsed.roots.map((r, i) => ({
|
|
516
|
+
value: r.label,
|
|
517
|
+
color: seriesColors[(i + colorOffset) % seriesColors.length]!,
|
|
518
|
+
})),
|
|
519
|
+
});
|
|
520
|
+
modeByName.set('Branch', 'branch');
|
|
521
|
+
if (activeMode === 'branch' || activeGroup === null) activeGroup = 'Branch';
|
|
522
|
+
|
|
523
|
+
return { groups, activeGroup, modeByName };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ============================================================
|
|
527
|
+
// Color scale (F1 — built here, not in the parser)
|
|
528
|
+
// ============================================================
|
|
529
|
+
|
|
530
|
+
interface HeatScale {
|
|
531
|
+
scale: (v: number) => string;
|
|
532
|
+
min: number;
|
|
533
|
+
max: number;
|
|
534
|
+
stops: string[];
|
|
535
|
+
signed: boolean;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function buildHeatScale(
|
|
539
|
+
parsed: ParsedTreemap,
|
|
540
|
+
palette: PaletteColors
|
|
541
|
+
): HeatScale | null {
|
|
542
|
+
if (!parsed.hasHeat) return null;
|
|
543
|
+
const values: number[] = [];
|
|
544
|
+
const collect = (nodes: readonly TreemapNode[]): void => {
|
|
545
|
+
for (const n of nodes) {
|
|
546
|
+
if (typeof n.heat === 'number') values.push(n.heat);
|
|
547
|
+
collect(n.children);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
collect(parsed.roots);
|
|
551
|
+
if (values.length === 0) return null;
|
|
552
|
+
|
|
553
|
+
const min = Math.min(...values);
|
|
554
|
+
const max = Math.max(...values);
|
|
555
|
+
const signed = min < 0 && max > 0;
|
|
556
|
+
|
|
557
|
+
const neutral = palette.surface;
|
|
558
|
+
const explicit = parsed.options.heatColors
|
|
559
|
+
.map((c) => resolveColor(c, palette) ?? c)
|
|
560
|
+
.filter((c): c is string => !!c);
|
|
561
|
+
|
|
562
|
+
let stops: string[];
|
|
563
|
+
let domain: number[];
|
|
564
|
+
|
|
565
|
+
if (explicit.length >= 2) {
|
|
566
|
+
// Two endpoints → low · neutral · high (wide-hue auto-midpoint).
|
|
567
|
+
stops = [explicit[0]!, neutral, explicit[1]!];
|
|
568
|
+
const mid = signed ? 0 : (min + max) / 2;
|
|
569
|
+
domain = [min, mid, max];
|
|
570
|
+
} else if (explicit.length === 1) {
|
|
571
|
+
stops = [neutral, explicit[0]!];
|
|
572
|
+
domain = [min, max];
|
|
573
|
+
} else if (signed) {
|
|
574
|
+
// Data-aware default: diverging, midpoint pinned at 0.
|
|
575
|
+
stops = [palette.colors.red, neutral, palette.colors.green];
|
|
576
|
+
domain = [min, 0, max];
|
|
577
|
+
} else {
|
|
578
|
+
// Data-aware default: sequential neutral → accent.
|
|
579
|
+
stops = [neutral, palette.primary];
|
|
580
|
+
domain = [min, max];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Guard against a degenerate (single-value) domain.
|
|
584
|
+
if (domain[0] === domain[domain.length - 1]) {
|
|
585
|
+
const last = stops[stops.length - 1]!;
|
|
586
|
+
return { scale: () => last, min, max, stops, signed };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const linear = scaleLinear<string, string>()
|
|
590
|
+
.domain(domain)
|
|
591
|
+
.range(stops)
|
|
592
|
+
.clamp(true);
|
|
593
|
+
// d3 interpolates to `rgb(...)` strings; normalize to hex so downstream
|
|
594
|
+
// helpers (mix/contrastText) that expect hex work on heat fills too.
|
|
595
|
+
return { scale: (v: number) => toHex(linear(v)), min, max, stops, signed };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Normalize a CSS color (`rgb(...)` or hex) to a `#rrggbb` hex string. */
|
|
599
|
+
function toHex(c: string): string {
|
|
600
|
+
if (c.startsWith('#')) return c;
|
|
601
|
+
const m = c.match(/rgba?\(([^)]+)\)/);
|
|
602
|
+
if (!m) return c;
|
|
603
|
+
const parts = m[1]!.split(',').map((s) => Math.round(parseFloat(s)));
|
|
604
|
+
const h = (n: number): string =>
|
|
605
|
+
Math.max(0, Math.min(255, n || 0))
|
|
606
|
+
.toString(16)
|
|
607
|
+
.padStart(2, '0');
|
|
608
|
+
return `#${h(parts[0] ?? 0)}${h(parts[1] ?? 0)}${h(parts[2] ?? 0)}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveColorMode(
|
|
612
|
+
parsed: ParsedTreemap,
|
|
613
|
+
override?: TreemapColorMode
|
|
614
|
+
): TreemapColorMode {
|
|
615
|
+
let mode = override ?? parsed.defaultColorMode;
|
|
616
|
+
if (mode === 'tag' && parsed.tagGroups.length === 0) {
|
|
617
|
+
mode = parsed.hasHeat ? 'heat' : 'branch';
|
|
618
|
+
}
|
|
619
|
+
if (mode === 'heat' && !parsed.hasHeat) mode = 'branch';
|
|
620
|
+
return mode;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ============================================================
|
|
624
|
+
// Helpers
|
|
625
|
+
// ============================================================
|
|
626
|
+
|
|
627
|
+
function drawFocusIcon(
|
|
628
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
629
|
+
w: number,
|
|
630
|
+
ink: string,
|
|
631
|
+
cy: number
|
|
632
|
+
): void {
|
|
633
|
+
// Small, subtle scope/target ring centered vertically at `cy` (the header
|
|
634
|
+
// band's middle). Opacity is driven by the SVG <style> (faint by default,
|
|
635
|
+
// clearer on cell hover). A generous transparent rect keeps it easy to click.
|
|
636
|
+
const r = 4.5;
|
|
637
|
+
const cx = w - r - 5;
|
|
638
|
+
const fi = g
|
|
639
|
+
.append('g')
|
|
640
|
+
.attr('class', 'dgmo-treemap-focus')
|
|
641
|
+
.attr('data-export-ignore', 'true');
|
|
642
|
+
fi.append('rect')
|
|
643
|
+
.attr('x', cx - 9)
|
|
644
|
+
.attr('y', cy - 9)
|
|
645
|
+
.attr('width', 18)
|
|
646
|
+
.attr('height', 18)
|
|
647
|
+
.attr('fill', 'transparent');
|
|
648
|
+
fi.append('circle')
|
|
649
|
+
.attr('cx', cx)
|
|
650
|
+
.attr('cy', cy)
|
|
651
|
+
.attr('r', r)
|
|
652
|
+
.attr('fill', 'none')
|
|
653
|
+
.attr('stroke', ink)
|
|
654
|
+
.attr('stroke-width', 1.25);
|
|
655
|
+
fi.append('circle')
|
|
656
|
+
.attr('cx', cx)
|
|
657
|
+
.attr('cy', cy)
|
|
658
|
+
.attr('r', 1.4)
|
|
659
|
+
.attr('fill', ink);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function clamp(x: number, lo: number, hi: number): number {
|
|
663
|
+
return Math.max(lo, Math.min(hi, x));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function clipLabel(s: string, maxWidth: number, fs: number): string {
|
|
667
|
+
if (measureText(s, fs) <= maxWidth) return s;
|
|
668
|
+
let lo = 0;
|
|
669
|
+
let hi = s.length;
|
|
670
|
+
while (lo < hi) {
|
|
671
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
672
|
+
if (measureText(s.slice(0, mid) + '…', fs) <= maxWidth) lo = mid;
|
|
673
|
+
else hi = mid - 1;
|
|
674
|
+
}
|
|
675
|
+
return lo <= 0 ? '' : s.slice(0, lo) + '…';
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Auto-compact like the map (1.2M, 940k); plain for small numbers. */
|
|
679
|
+
export function compactNumber(v: number): string {
|
|
680
|
+
const abs = Math.abs(v);
|
|
681
|
+
if (abs >= 1e9) return strip(v / 1e9) + 'B';
|
|
682
|
+
if (abs >= 1e6) return strip(v / 1e6) + 'M';
|
|
683
|
+
if (abs >= 1e3) return strip(v / 1e3) + 'k';
|
|
684
|
+
return strip(Math.round(v * 100) / 100);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function strip(n: number): string {
|
|
688
|
+
return parseFloat(
|
|
689
|
+
n.toFixed(n < 10 && !Number.isInteger(n) ? 1 : 0)
|
|
690
|
+
).toString();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function formatPct(frac: number): string {
|
|
694
|
+
const pct = frac * 100;
|
|
695
|
+
if (pct >= 10) return `${Math.round(pct)}%`;
|
|
696
|
+
if (pct >= 1) return `${pct.toFixed(0)}%`;
|
|
697
|
+
return `${pct.toFixed(1)}%`;
|
|
698
|
+
}
|