@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,189 @@
1
+ // ============================================================
2
+ // Treemap — Squarified Layout (d3-hierarchy)
3
+ // ============================================================
4
+ //
5
+ // Pure geometry: takes the parsed roots + a rectangle and returns positioned
6
+ // cells. Two data transforms happen here so the export renderer and the app's
7
+ // interactive renderer share them:
8
+ // - `other-below N` rollup (per-parent, against the parent's summed total).
9
+ // - `depth N` windowing (collapse nodes at the cap into solid drillable blocks).
10
+
11
+ import {
12
+ hierarchy,
13
+ treemap,
14
+ treemapSquarify,
15
+ type HierarchyRectangularNode,
16
+ } from 'd3-hierarchy';
17
+ import type { TreemapNode } from './types';
18
+
19
+ /** Internal datum fed to d3-hierarchy (carries the original node ref). */
20
+ interface LayoutDatum {
21
+ /** Original parsed node; undefined for the synthetic root. */
22
+ ref?: TreemapNode;
23
+ label: string;
24
+ /** Leaf size (only meaningful on leaves). */
25
+ value?: number;
26
+ heat?: number;
27
+ children?: LayoutDatum[];
28
+ /** Source line for click-to-source wiring (when available). */
29
+ lineNumber?: number;
30
+ }
31
+
32
+ export interface TreemapCell {
33
+ /** Original parsed node, or null for the synthetic root. */
34
+ readonly node: TreemapNode | null;
35
+ readonly label: string;
36
+ readonly x0: number;
37
+ readonly y0: number;
38
+ readonly x1: number;
39
+ readonly y1: number;
40
+ /** 1-based depth within the laid-out (post-drill) tree. */
41
+ readonly depth: number;
42
+ /** Summed value (d3 rollup). */
43
+ readonly value: number;
44
+ /** Has children AND is drawn as a container (depth below the cap). */
45
+ readonly isContainer: boolean;
46
+ /** Has children but sits AT the depth cap — a solid drillable block. */
47
+ readonly isCollapsed: boolean;
48
+ /** Index of the top-level ancestor (drives branch-mode hue). */
49
+ readonly topIndex: number;
50
+ readonly pctOfRoot: number;
51
+ readonly pctOfParent: number;
52
+ /** Own heat, else the mean of descendant leaf heats; undefined if none. */
53
+ readonly heat?: number;
54
+ /** Path of labels from the laid-out root (for tooltips / data-node-path). */
55
+ readonly path: readonly string[];
56
+ readonly lineNumber?: number;
57
+ }
58
+
59
+ export interface TreemapLayoutResult {
60
+ readonly cells: readonly TreemapCell[];
61
+ readonly total: number;
62
+ }
63
+
64
+ export interface TreemapLayoutOptions {
65
+ readonly width: number;
66
+ readonly height: number;
67
+ /** Top gutter reserved for parent header bars (0 when headers off). */
68
+ readonly headerH: number;
69
+ readonly paddingInner?: number;
70
+ readonly paddingOuter?: number;
71
+ /** Render budget; nodes at this depth collapse to solid blocks. */
72
+ readonly maxDepth?: number;
73
+ }
74
+
75
+ /** Summed value of a parsed node (leaf value or sum of descendants). */
76
+ export function sumValue(node: TreemapNode): number {
77
+ if (node.children.length === 0) return node.value ?? 0;
78
+ let s = 0;
79
+ for (const c of node.children) s += sumValue(c);
80
+ return s;
81
+ }
82
+
83
+ /** Convert a parsed node into a layout datum. */
84
+ function toDatum(node: TreemapNode): LayoutDatum {
85
+ if (node.children.length === 0) {
86
+ return {
87
+ ref: node,
88
+ label: node.label,
89
+ value: node.value ?? 0,
90
+ ...(node.heat !== undefined && { heat: node.heat }),
91
+ lineNumber: node.lineNumber,
92
+ };
93
+ }
94
+
95
+ return {
96
+ ref: node,
97
+ label: node.label,
98
+ ...(node.heat !== undefined && { heat: node.heat }),
99
+ lineNumber: node.lineNumber,
100
+ children: node.children.map((c) => toDatum(c)),
101
+ };
102
+ }
103
+
104
+ /** Mean heat across the leaves under a datum (own heat if it is a leaf). */
105
+ function datumHeat(
106
+ node: HierarchyRectangularNode<LayoutDatum>
107
+ ): number | undefined {
108
+ if (node.data.heat !== undefined) return node.data.heat;
109
+ const xs: number[] = [];
110
+ for (const l of node.leaves()) {
111
+ if (l.data.heat !== undefined) xs.push(l.data.heat);
112
+ }
113
+ if (xs.length === 0) return undefined;
114
+ return xs.reduce((a, b) => a + b, 0) / xs.length;
115
+ }
116
+
117
+ export function layoutTreemap(
118
+ roots: readonly TreemapNode[],
119
+ opts: TreemapLayoutOptions
120
+ ): TreemapLayoutResult {
121
+ const maxDepth = opts.maxDepth ?? Infinity;
122
+ const rootDatum: LayoutDatum = {
123
+ label: '__root',
124
+ children: roots.map((r) => toDatum(r)),
125
+ };
126
+
127
+ const h = hierarchy<LayoutDatum>(rootDatum)
128
+ .sum((d) => (d.children?.length ? 0 : (d.value ?? 0)))
129
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
130
+
131
+ const hroot = treemap<LayoutDatum>()
132
+ .tile(treemapSquarify)
133
+ .size([opts.width, opts.height])
134
+ .paddingOuter(opts.paddingOuter ?? 3)
135
+ .paddingTop(opts.headerH > 0 ? opts.headerH : (opts.paddingInner ?? 2))
136
+ .paddingInner(opts.paddingInner ?? 2)
137
+ .round(true)(h);
138
+
139
+ const total = hroot.value ?? 0;
140
+
141
+ // Tag each node with the index of its depth-1 ancestor (branch hue).
142
+ (hroot.children ?? []).forEach((top, i) => {
143
+ top.each((d) => {
144
+ (d as { _topIndex?: number })._topIndex = i;
145
+ });
146
+ });
147
+
148
+ const cells: TreemapCell[] = [];
149
+ for (const d of hroot.descendants()) {
150
+ if (d.depth === 0) continue; // skip synthetic root
151
+ if (d.depth > maxDepth) continue; // beyond the render window
152
+
153
+ const hasChildren = !!d.children && d.children.length > 0;
154
+ const isCollapsed = hasChildren && d.depth >= maxDepth;
155
+ const isContainer = hasChildren && !isCollapsed;
156
+
157
+ const path: string[] = [];
158
+ let p: HierarchyRectangularNode<LayoutDatum> | null = d;
159
+ while (p && p.depth > 0) {
160
+ path.unshift(p.data.label);
161
+ p = p.parent;
162
+ }
163
+
164
+ const heatVal = datumHeat(d);
165
+ cells.push({
166
+ node: d.data.ref ?? null,
167
+ label: d.data.label,
168
+ x0: d.x0,
169
+ y0: d.y0,
170
+ x1: d.x1,
171
+ y1: d.y1,
172
+ depth: d.depth,
173
+ value: d.value ?? 0,
174
+ isContainer,
175
+ isCollapsed,
176
+ topIndex: (d as { _topIndex?: number })._topIndex ?? 0,
177
+ pctOfRoot: total > 0 ? (d.value ?? 0) / total : 0,
178
+ pctOfParent:
179
+ d.parent && (d.parent.value ?? 0) > 0
180
+ ? (d.value ?? 0) / (d.parent.value ?? 1)
181
+ : 1,
182
+ ...(heatVal !== undefined && { heat: heatVal }),
183
+ path,
184
+ ...(d.data.lineNumber !== undefined && { lineNumber: d.data.lineNumber }),
185
+ });
186
+ }
187
+
188
+ return { cells, total };
189
+ }
@@ -0,0 +1,475 @@
1
+ // ============================================================
2
+ // Treemap — Parser
3
+ // ============================================================
4
+ //
5
+ // Structure mirrors the mindmap parser (indent-stack hierarchy + §1.4 unified
6
+ // metadata via splitNameAndMeta). Treemap-specific bits:
7
+ // - node SIZE is the bare trailing number on a leaf (§1.5 funnel/sankey idiom);
8
+ // parents auto-sum, so a trailing number on a branch is ignored with a warn.
9
+ // - `heat:` per-node metric + `heat <Label> [low] [high]` ramp directive.
10
+ // - `depth N`, `no-values|no-percent|no-headers|no-legend`.
11
+
12
+ import type { PaletteColors } from '../palettes';
13
+ import {
14
+ dataCommaRemovedMessage,
15
+ formatDgmoError,
16
+ makeDgmoError,
17
+ makeFail,
18
+ pipeOperatorRemovedMessage,
19
+ suggest,
20
+ METADATA_DIAGNOSTIC_CODES,
21
+ } from '../diagnostics';
22
+ import type { DgmoError } from '../diagnostics';
23
+ import type { Writable } from '../utils/brand';
24
+ import type { TagGroup } from '../utils/tag-groups';
25
+ import {
26
+ matchTagBlockHeading,
27
+ emitTagLegacyDiagnostic,
28
+ validateTagValues,
29
+ validateTagGroupNames,
30
+ stripDefaultModifier,
31
+ finalizeAutoTagColors,
32
+ cascadeTagMetadata,
33
+ AUTO_TAG_COLOR_SENTINEL,
34
+ tagAttrKey,
35
+ } from '../utils/tag-groups';
36
+ import {
37
+ measureIndent,
38
+ extractColor,
39
+ parseFirstLine,
40
+ peelRampColors,
41
+ stripQuotes,
42
+ splitNameAndMeta,
43
+ warnUnknownMetaKeys,
44
+ } from '../utils/parsing';
45
+ import {
46
+ TREEMAP_REGISTRY,
47
+ withTagAliases,
48
+ } from '../utils/reserved-key-registry';
49
+ import type {
50
+ ParsedTreemap,
51
+ TreemapNode,
52
+ TreemapOptions,
53
+ TreemapColorMode,
54
+ } from './types';
55
+
56
+ // Diagnostic codes specific to the treemap value model.
57
+ export const TREEMAP_DIAGNOSTIC_CODES = {
58
+ NEGATIVE_VALUE: 'E_TREEMAP_NEGATIVE_VALUE',
59
+ LEAF_NO_VALUE: 'W_TREEMAP_LEAF_NO_VALUE',
60
+ BRANCH_VALUE_IGNORED: 'W_TREEMAP_BRANCH_VALUE_IGNORED',
61
+ } as const;
62
+
63
+ /** A bare numeric token: optional sign, digits with `_`/`,`/`.` separators. */
64
+ const VALUE_TOKEN_RE = /^(.+?)\s+(-?\d[\d_,.]*)$/;
65
+
66
+ /** True when `s` is wrapped in a matching pair of single or double quotes. */
67
+ function isFullyQuoted(s: string): boolean {
68
+ return (
69
+ s.length >= 2 &&
70
+ ((s.startsWith('"') && s.endsWith('"')) ||
71
+ (s.startsWith("'") && s.endsWith("'")))
72
+ );
73
+ }
74
+
75
+ export function parseTreemap(
76
+ content: string,
77
+ palette?: PaletteColors
78
+ ): ParsedTreemap {
79
+ const options: TreemapOptions = {
80
+ heatColors: [],
81
+ noValues: false,
82
+ noPercent: false,
83
+ noHeaders: false,
84
+ noLegend: false,
85
+ };
86
+ const result: Writable<ParsedTreemap> = {
87
+ type: 'treemap',
88
+ title: null,
89
+ titleLineNumber: null,
90
+ roots: [],
91
+ tagGroups: [],
92
+ hasHeat: false,
93
+ defaultColorMode: 'branch',
94
+ options,
95
+ diagnostics: [],
96
+ error: null,
97
+ };
98
+
99
+ const fail = makeFail(result);
100
+ const pushError = (line: number, message: string, code?: string): void => {
101
+ const diag = makeDgmoError(line, message, 'error', code);
102
+ result.diagnostics.push(diag);
103
+ if (!result.error) result.error = formatDgmoError(diag);
104
+ };
105
+ const pushWarning = (line: number, message: string, code?: string): void => {
106
+ result.diagnostics.push(makeDgmoError(line, message, 'warning', code));
107
+ };
108
+
109
+ if (!content?.trim()) {
110
+ return fail(0, 'No content provided');
111
+ }
112
+
113
+ const lines = content.split('\n');
114
+ let contentStarted = false;
115
+ let nodeCounter = 0;
116
+
117
+ let currentTagGroup: Writable<TagGroup> | null = null;
118
+ const aliasMap = new Map<string, string>();
119
+
120
+ // Indent stack for hierarchy tracking.
121
+ const indentStack: { node: Writable<TreemapNode>; indent: number }[] = [];
122
+
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const line = lines[i]!;
125
+ const lineNumber = i + 1;
126
+ const trimmed = line.trim();
127
+
128
+ if (!trimmed) {
129
+ currentTagGroup = null;
130
+ continue;
131
+ }
132
+ if (trimmed.startsWith('//')) continue;
133
+
134
+ // --- Header phase: first line declares `treemap [Title]` ---
135
+ if (!contentStarted) {
136
+ const firstLine = parseFirstLine(trimmed);
137
+ if (firstLine && result.titleLineNumber === null && i === 0) {
138
+ if (firstLine.chartType !== 'treemap') {
139
+ let msg = `Expected chart type "treemap", got "${firstLine.chartType}"`;
140
+ const hint = suggest(firstLine.chartType, ['treemap']);
141
+ if (hint) msg += `. ${hint}`;
142
+ return fail(lineNumber, msg);
143
+ }
144
+ result.title = firstLine.title ?? null;
145
+ result.titleLineNumber = lineNumber;
146
+ continue;
147
+ }
148
+ }
149
+
150
+ // Tag group heading: `tag Team as t`
151
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
152
+ if (tagBlockMatch) {
153
+ emitTagLegacyDiagnostic(tagBlockMatch, lineNumber, result.diagnostics);
154
+ if (contentStarted) {
155
+ pushError(
156
+ lineNumber,
157
+ 'Tag groups must appear before treemap content',
158
+ 'E_TAG_DECLARED_AFTER_CONTENT'
159
+ );
160
+ continue;
161
+ }
162
+ currentTagGroup = {
163
+ name: tagBlockMatch.name,
164
+ ...(tagBlockMatch.alias !== undefined && {
165
+ alias: tagBlockMatch.alias,
166
+ }),
167
+ entries: [],
168
+ lineNumber,
169
+ };
170
+ // Register BOTH the alias and the canonical tag name in the alias map so
171
+ // `t:` and `Team:` both dispatch as the same tag metadata key.
172
+ if (tagBlockMatch.alias) {
173
+ aliasMap.set(
174
+ tagBlockMatch.alias.toLowerCase(),
175
+ tagAttrKey(tagBlockMatch.name)
176
+ );
177
+ }
178
+ aliasMap.set(
179
+ tagAttrKey(tagBlockMatch.name),
180
+ tagAttrKey(tagBlockMatch.name)
181
+ );
182
+ result.tagGroups.push(currentTagGroup);
183
+ continue;
184
+ }
185
+
186
+ // Tag group entries (indented `Value color` under a tag heading).
187
+ if (currentTagGroup && !contentStarted) {
188
+ const indent = measureIndent(line);
189
+ if (indent > 0) {
190
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
191
+ const { label, color } = extractColor(
192
+ cleanEntry,
193
+ palette,
194
+ result.diagnostics,
195
+ lineNumber
196
+ );
197
+ if (isDefault) {
198
+ currentTagGroup.defaultValue = label;
199
+ } else if (currentTagGroup.entries.length === 0) {
200
+ currentTagGroup.defaultValue = label;
201
+ }
202
+ currentTagGroup.entries.push({
203
+ value: label,
204
+ color: color ?? AUTO_TAG_COLOR_SENTINEL,
205
+ lineNumber,
206
+ });
207
+ continue;
208
+ }
209
+ currentTagGroup = null;
210
+ }
211
+
212
+ // Directives (header phase only, indent 0).
213
+ if (!contentStarted && measureIndent(line) === 0) {
214
+ if (handleDirective(trimmed, options, result, pushWarning, lineNumber)) {
215
+ continue;
216
+ }
217
+ }
218
+
219
+ // --- Content phase ---
220
+ contentStarted = true;
221
+ currentTagGroup = null;
222
+
223
+ const indent = measureIndent(line);
224
+ const node = parseNodeLine(
225
+ trimmed,
226
+ lineNumber,
227
+ ++nodeCounter,
228
+ aliasMap,
229
+ result.diagnostics,
230
+ pushWarning
231
+ );
232
+ if (node.heat !== undefined) result.hasHeat = true;
233
+ attachNode(node, indent, indentStack, result);
234
+ }
235
+
236
+ // Finalize auto tag colors before any color resolution.
237
+ finalizeAutoTagColors(result.tagGroups as Writable<TagGroup>[], palette);
238
+
239
+ // Value resolution post-pass: branch vs leaf is only known once the whole
240
+ // tree is built (a node is a branch iff it has children).
241
+ const allNodes: TreemapNode[] = [];
242
+ const collectAll = (nodes: readonly TreemapNode[]): void => {
243
+ for (const n of nodes) {
244
+ allNodes.push(n);
245
+ collectAll(n.children);
246
+ }
247
+ };
248
+ collectAll(result.roots);
249
+
250
+ for (const n of allNodes) {
251
+ const node = n as Writable<TreemapNode>;
252
+ const isBranch = node.children.length > 0;
253
+ if (isBranch) {
254
+ if (node.value !== undefined) {
255
+ pushWarning(
256
+ node.lineNumber,
257
+ `"${node.label}" is a branch — its trailing number is ignored; the area is the auto-sum of its children`,
258
+ TREEMAP_DIAGNOSTIC_CODES.BRANCH_VALUE_IGNORED
259
+ );
260
+ delete node.value;
261
+ }
262
+ } else if (node.value === undefined) {
263
+ pushWarning(
264
+ node.lineNumber,
265
+ `Leaf "${node.label}" has no value — it renders with zero area`,
266
+ TREEMAP_DIAGNOSTIC_CODES.LEAF_NO_VALUE
267
+ );
268
+ node.value = 0;
269
+ } else if (node.value < 0) {
270
+ pushError(
271
+ node.lineNumber,
272
+ `Leaf "${node.label}" has a negative value (${node.value}) — treemap sizes must be ≥ 0`,
273
+ TREEMAP_DIAGNOSTIC_CODES.NEGATIVE_VALUE
274
+ );
275
+ }
276
+ }
277
+
278
+ if (result.hasHeat || options.heatLabel !== undefined) {
279
+ result.hasHeat = true;
280
+ }
281
+
282
+ // Tag validation + inheritance (mirrors mindmap).
283
+ if (result.tagGroups.length > 0) {
284
+ validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
285
+ validateTagGroupNames(result.tagGroups, pushWarning);
286
+ cascadeTagMetadata(result.roots, result.tagGroups);
287
+ }
288
+
289
+ // Resolve the source-declared default color mode: tag > heat > branch.
290
+ result.defaultColorMode = resolveDefaultColorMode(result);
291
+
292
+ if (result.roots.length === 0 && !result.error) {
293
+ const diag = makeDgmoError(1, 'No nodes found in treemap');
294
+ result.diagnostics.push(diag);
295
+ result.error = formatDgmoError(diag);
296
+ }
297
+
298
+ return result;
299
+ }
300
+
301
+ // ============================================================
302
+ // Directives
303
+ // ============================================================
304
+
305
+ function handleDirective(
306
+ trimmed: string,
307
+ options: Writable<TreemapOptions>,
308
+ result: Writable<ParsedTreemap>,
309
+ pushWarning: (line: number, message: string) => void,
310
+ lineNumber: number
311
+ ): boolean {
312
+ const depthMatch = trimmed.match(/^depth\s+(-?\d+)\s*$/i);
313
+ if (depthMatch) {
314
+ const n = parseInt(depthMatch[1]!, 10);
315
+ if (n >= 1) options.maxDepth = n;
316
+ else pushWarning(lineNumber, '"depth" must be ≥ 1 — ignored');
317
+ return true;
318
+ }
319
+
320
+ // `other-below` (small-leaf rollup into an "Other" bucket) was removed.
321
+ // Still recognize the line so it doesn't fall through to node parsing (which
322
+ // would render a junk "other-below" cell), but ignore it with a hint.
323
+ if (/^other-below\b/i.test(trimmed)) {
324
+ pushWarning(
325
+ lineNumber,
326
+ '"other-below" is no longer supported — remove this line'
327
+ );
328
+ return true;
329
+ }
330
+
331
+ // heat <Label> [low] [high] — names + (optionally) colors the value ramp.
332
+ const heatMatch = trimmed.match(/^heat\s+(.+)$/i);
333
+ if (heatMatch) {
334
+ const { label, low, high } = peelRampColors(heatMatch[1]!.trim());
335
+ options.heatLabel = label || 'Value';
336
+ const colors: string[] = [];
337
+ if (low !== undefined) colors.push(low);
338
+ if (high !== undefined) colors.push(high);
339
+ options.heatColors = colors;
340
+ result.hasHeat = true;
341
+ return true;
342
+ }
343
+
344
+ const noMatch = trimmed.match(/^no-(values|percent|headers|legend)\s*$/i);
345
+ if (noMatch) {
346
+ const key = noMatch[1]!.toLowerCase();
347
+ if (key === 'values') options.noValues = true;
348
+ else if (key === 'percent') options.noPercent = true;
349
+ else if (key === 'headers') options.noHeaders = true;
350
+ else if (key === 'legend') options.noLegend = true;
351
+ return true;
352
+ }
353
+
354
+ return false;
355
+ }
356
+
357
+ function resolveDefaultColorMode(result: ParsedTreemap): TreemapColorMode {
358
+ if (result.tagGroups.length > 0) return 'tag';
359
+ if (result.hasHeat) return 'heat';
360
+ return 'branch';
361
+ }
362
+
363
+ // ============================================================
364
+ // Node lines
365
+ // ============================================================
366
+
367
+ function parseNodeLine(
368
+ trimmed: string,
369
+ lineNumber: number,
370
+ counter: number,
371
+ aliasMap: Map<string, string>,
372
+ diagnostics: DgmoError[],
373
+ pushWarning: (line: number, message: string, code?: string) => void
374
+ ): Writable<TreemapNode> {
375
+ // Legacy pipe-metadata is removed language-wide.
376
+ if (trimmed.includes('|')) {
377
+ diagnostics.push(
378
+ makeDgmoError(
379
+ lineNumber,
380
+ pipeOperatorRemovedMessage(),
381
+ 'error',
382
+ METADATA_DIAGNOSTIC_CODES.PIPE_OPERATOR_REMOVED
383
+ )
384
+ );
385
+ }
386
+
387
+ const registry = withTagAliases(TREEMAP_REGISTRY, new Set(aliasMap.keys()));
388
+ const split = splitNameAndMeta(
389
+ trimmed,
390
+ registry,
391
+ aliasMap,
392
+ undefined,
393
+ diagnostics,
394
+ lineNumber
395
+ );
396
+ warnUnknownMetaKeys(
397
+ split.meta,
398
+ registry,
399
+ (msg) => diagnostics.push(makeDgmoError(lineNumber, msg, 'warning')),
400
+ split.name
401
+ );
402
+
403
+ const metadata: Record<string, string> = { ...split.meta };
404
+
405
+ // Heat metric (optional second numeric).
406
+ let heat: number | undefined;
407
+ if ('heat' in metadata) {
408
+ const h = parseFloat(metadata['heat']!.replace(/_/g, ''));
409
+ if (Number.isFinite(h)) heat = h;
410
+ delete metadata['heat'];
411
+ }
412
+
413
+ // Value resolution: a fully-quoted name preserves trailing digits (no value
414
+ // peel); otherwise peel a bare trailing number as the leaf size. A quoted
415
+ // label may itself carry a value (`"Region 5" 100`) — peel the value first,
416
+ // then strip the quotes from the remaining label.
417
+ const rawName = split.name;
418
+ let label: string;
419
+ let value: number | undefined;
420
+
421
+ if (isFullyQuoted(rawName)) {
422
+ label = stripQuotes(rawName);
423
+ } else {
424
+ const vm = rawName.match(VALUE_TOKEN_RE);
425
+ if (vm) {
426
+ const labelPart = vm[1]!.trim();
427
+ const token = vm[2]!;
428
+ if (token.includes(',')) {
429
+ pushWarning(lineNumber, dataCommaRemovedMessage(labelPart));
430
+ }
431
+ const cleaned = token.replace(/[_,]/g, '');
432
+ const parsed = parseFloat(cleaned);
433
+ if (Number.isFinite(parsed)) {
434
+ value = parsed;
435
+ label = isFullyQuoted(labelPart) ? stripQuotes(labelPart) : labelPart;
436
+ } else {
437
+ label = rawName;
438
+ }
439
+ } else {
440
+ label = rawName;
441
+ }
442
+ }
443
+
444
+ return {
445
+ id: `treemap-node-${counter}`,
446
+ label,
447
+ ...(value !== undefined && { value }),
448
+ ...(heat !== undefined && { heat }),
449
+ metadata,
450
+ children: [],
451
+ lineNumber,
452
+ };
453
+ }
454
+
455
+ function attachNode(
456
+ node: Writable<TreemapNode>,
457
+ indent: number,
458
+ indentStack: { node: Writable<TreemapNode>; indent: number }[],
459
+ result: Writable<ParsedTreemap>
460
+ ): void {
461
+ while (indentStack.length > 0) {
462
+ const top = indentStack[indentStack.length - 1]!;
463
+ if (top.indent < indent) break;
464
+ indentStack.pop();
465
+ }
466
+
467
+ if (indentStack.length > 0) {
468
+ const parent = indentStack[indentStack.length - 1]!.node;
469
+ parent.children.push(node);
470
+ } else {
471
+ result.roots.push(node);
472
+ }
473
+
474
+ indentStack.push({ node, indent });
475
+ }