@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.
@@ -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
+ }