tplm-lang 0.1.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.
Files changed (36) hide show
  1. package/README.md +357 -0
  2. package/dist/compiler/grid-spec-builder.d.ts +30 -0
  3. package/dist/compiler/grid-spec-builder.js +1836 -0
  4. package/dist/compiler/index.d.ts +11 -0
  5. package/dist/compiler/index.js +13 -0
  6. package/dist/compiler/malloy-generator.d.ts +36 -0
  7. package/dist/compiler/malloy-generator.js +141 -0
  8. package/dist/compiler/multi-query-utils.d.ts +42 -0
  9. package/dist/compiler/multi-query-utils.js +185 -0
  10. package/dist/compiler/query-plan-generator.d.ts +77 -0
  11. package/dist/compiler/query-plan-generator.js +1456 -0
  12. package/dist/compiler/table-spec-builder.d.ts +11 -0
  13. package/dist/compiler/table-spec-builder.js +588 -0
  14. package/dist/compiler/table-spec.d.ts +434 -0
  15. package/dist/compiler/table-spec.js +274 -0
  16. package/dist/executor/index.d.ts +71 -0
  17. package/dist/executor/index.js +232 -0
  18. package/dist/index.d.ts +214 -0
  19. package/dist/index.js +220 -0
  20. package/dist/parser/ast.d.ts +253 -0
  21. package/dist/parser/ast.js +164 -0
  22. package/dist/parser/chevrotain-parser.d.ts +118 -0
  23. package/dist/parser/chevrotain-parser.js +1266 -0
  24. package/dist/parser/index.d.ts +30 -0
  25. package/dist/parser/index.js +36 -0
  26. package/dist/parser/parser.d.ts +4 -0
  27. package/dist/parser/parser.js +4354 -0
  28. package/dist/parser/prettifier.d.ts +14 -0
  29. package/dist/parser/prettifier.js +380 -0
  30. package/dist/renderer/grid-renderer.d.ts +19 -0
  31. package/dist/renderer/grid-renderer.js +541 -0
  32. package/dist/renderer/index.d.ts +4 -0
  33. package/dist/renderer/index.js +4 -0
  34. package/package.json +67 -0
  35. package/packages/parser/tpl.pegjs +568 -0
  36. package/packages/renderer/tpl-table.css +182 -0
@@ -0,0 +1,1836 @@
1
+ /**
2
+ * Grid Spec Builder
3
+ *
4
+ * Transforms TableSpec + query results into a GridSpec for rendering.
5
+ *
6
+ * Key responsibilities:
7
+ * 1. Build row header hierarchy from axis tree + actual dimension values
8
+ * 2. Build column header hierarchy from axis tree + actual dimension values
9
+ * 3. Create cell lookup function mapping (rowPath, colPath) → cell value
10
+ * 4. Handle totals, nested data, and cross-dimensional aggregation (ACROSS)
11
+ */
12
+ import { collectBranches, } from './table-spec.js';
13
+ /**
14
+ * Build a GridSpec from a TableSpec and query results.
15
+ *
16
+ * @param spec The table specification
17
+ * @param plan The query plan
18
+ * @param results Query results indexed by query ID
19
+ * @param malloyQueries Optional Malloy query specs (for axis inversion info)
20
+ */
21
+ export function buildGridSpec(spec, plan, results, malloyQueries) {
22
+ // Build maps of query ID to special handling flags
23
+ const invertedQueries = new Set();
24
+ const flatQueries = new Set();
25
+ if (malloyQueries) {
26
+ for (const mq of malloyQueries) {
27
+ if (mq.axesInverted) {
28
+ invertedQueries.add(mq.id);
29
+ }
30
+ if (mq.isFlatQuery) {
31
+ flatQueries.add(mq.id);
32
+ }
33
+ }
34
+ }
35
+ // Build header structures from axis trees
36
+ let rowHeaders = buildHeaderHierarchy(spec.rowAxis, plan, results, 'row');
37
+ const colHeaders = buildHeaderHierarchy(spec.colAxis, plan, results, 'col');
38
+ // Handle aggregate-only row axis: when rowHeaders is empty but we have aggregates,
39
+ // create a synthetic row header to represent the single aggregate row.
40
+ // This ensures the table body has at least one row to display data.
41
+ if (rowHeaders.length === 0 && spec.aggregates.length > 0) {
42
+ // Check if the row axis contains only aggregates (no dimensions)
43
+ const rowDimensions = collectDimensionsFromAxis(spec.rowAxis);
44
+ if (rowDimensions.length === 0) {
45
+ // Create synthetic row headers for each aggregate
46
+ // If there's only one aggregate, create a single row with the aggregate label
47
+ // If multiple aggregates, each gets its own row
48
+ if (spec.aggregates.length === 1) {
49
+ const agg = spec.aggregates[0];
50
+ rowHeaders = [{
51
+ type: 'dimension',
52
+ dimension: '_aggregate',
53
+ value: agg.label ?? formatAggregateName(agg.measure, agg.aggregation),
54
+ label: agg.label,
55
+ span: 1,
56
+ depth: 0,
57
+ path: [{ type: 'aggregate', name: agg.name }],
58
+ }];
59
+ }
60
+ else {
61
+ // Multiple aggregates - each gets its own row
62
+ rowHeaders = spec.aggregates.map((agg, idx) => ({
63
+ type: 'dimension',
64
+ dimension: '_aggregate',
65
+ value: agg.label ?? formatAggregateName(agg.measure, agg.aggregation),
66
+ label: agg.label,
67
+ span: 1,
68
+ depth: 0,
69
+ path: [{ type: 'sibling', index: idx }, { type: 'aggregate', name: agg.name }],
70
+ }));
71
+ }
72
+ }
73
+ }
74
+ // Build cell lookup (value-based)
75
+ const cellLookup = buildCellLookup(spec, plan, results, invertedQueries, flatQueries);
76
+ // Check for totals
77
+ const hasRowTotal = axisHasTotal(spec.rowAxis);
78
+ const hasColTotal = axisHasTotal(spec.colAxis);
79
+ // Determine if corner-style row headers should be used
80
+ // Only valid when rowHeaders:above is set AND row axis doesn't have siblings at root
81
+ const useCornerRowHeaders = shouldUseCornerRowHeaders(spec);
82
+ const cornerRowLabels = useCornerRowHeaders ? extractRowDimensionLabels(spec.rowAxis) : undefined;
83
+ // For left mode (when siblings exist), extract labels to show in corner
84
+ // Only show labels for dimensions that have custom labels
85
+ const leftModeRowLabels = !useCornerRowHeaders && rowHeaders.length > 0
86
+ ? extractLeftModeRowLabels(rowHeaders)
87
+ : undefined;
88
+ return {
89
+ rowHeaders,
90
+ colHeaders,
91
+ getCell: (rowValues, colValues, aggregate) => cellLookup.get(rowValues, colValues, aggregate),
92
+ aggregates: spec.aggregates,
93
+ hasRowTotal,
94
+ hasColTotal,
95
+ options: spec.options,
96
+ useCornerRowHeaders,
97
+ cornerRowLabels,
98
+ leftModeRowLabels,
99
+ };
100
+ }
101
+ // ---
102
+ // HEADER HIERARCHY BUILDER
103
+ // ---
104
+ /**
105
+ * Build header hierarchy from an axis tree and query results.
106
+ *
107
+ * This walks the axis tree structure and populates it with actual
108
+ * values from the query results.
109
+ */
110
+ function buildHeaderHierarchy(tree, plan, results, axis) {
111
+ if (!tree)
112
+ return [];
113
+ // Collect all branches to understand the structure
114
+ const branches = collectBranches(tree);
115
+ // For each branch, find the corresponding query and extract values
116
+ const headers = [];
117
+ return buildHeaderNodes(tree, plan, results, axis, [], 0);
118
+ }
119
+ /**
120
+ * Recursively build header nodes from an axis tree.
121
+ *
122
+ * Key insight: Aggregates are NOT header levels - they define what value
123
+ * goes in cells. The header tree ends at the last dimension.
124
+ * Aggregate children mean "this dimension cell has these values".
125
+ *
126
+ * @param parentValues Context: dimension values of parent headers (e.g., state=CA)
127
+ * Used to filter child dimension values to only those that exist under this parent.
128
+ */
129
+ function buildHeaderNodes(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
130
+ if (!node)
131
+ return [];
132
+ switch (node.nodeType) {
133
+ case 'dimension':
134
+ return buildDimensionHeaders(node, plan, results, axis, currentPath, depth, parentValues);
135
+ case 'total':
136
+ return buildTotalHeaders(node, plan, results, axis, currentPath, depth, parentValues);
137
+ case 'siblings':
138
+ return buildSiblingHeaders(node, plan, results, axis, currentPath, depth, parentValues);
139
+ case 'aggregate':
140
+ // Single aggregates (even with labels) should NOT create header entries.
141
+ // Header entries for aggregates are only appropriate when there are
142
+ // multiple sibling aggregates (e.g., income.sum "Sum" | income.mean "Average").
143
+ // The buildSiblingHeaders function handles that case.
144
+ //
145
+ // For a single aggregate like `gender * income.sum "Total Income"`,
146
+ // the label should be used for column headers, not create row entries.
147
+ return [];
148
+ case 'percentageAggregate':
149
+ // Single percentage aggregates (even with labels) should NOT create header entries.
150
+ // Header entries are only appropriate when there are multiple sibling aggregates.
151
+ // The buildSiblingHeaders function handles that case.
152
+ return [];
153
+ }
154
+ }
155
+ /**
156
+ * Build headers for a dimension node.
157
+ *
158
+ * When a dimension has a custom label (non-empty) and is not already within a sibling group,
159
+ * we create a sibling-label wrapper to display the label above the dimension values.
160
+ *
161
+ * @param parentValues Context from parent dimensions for filtering child values
162
+ */
163
+ function buildDimensionHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
164
+ // Check if this dimension has a custom non-empty label and we're not already in a sibling context
165
+ // If so, we should create a sibling-label wrapper to display the label above dimension values
166
+ const hasCustomLabel = node.label !== undefined && node.label !== '';
167
+ const alreadyInSiblingContext = currentPath.some(seg => seg.type === 'sibling');
168
+ if (hasCustomLabel && !alreadyInSiblingContext) {
169
+ // Create a sibling-label wrapper for this dimension's label
170
+ // Build value headers as children at depth+1
171
+ const valueHeaders = buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth + 1, parentValues);
172
+ const span = valueHeaders.reduce((sum, c) => sum + c.span, 0);
173
+ return [{
174
+ type: 'sibling-label',
175
+ dimension: node.name,
176
+ value: node.label,
177
+ label: node.label,
178
+ span: span || 1,
179
+ depth,
180
+ children: valueHeaders.length > 0 ? valueHeaders : undefined,
181
+ path: currentPath,
182
+ }];
183
+ }
184
+ // No custom label or already in sibling context - build value headers directly
185
+ return buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth, parentValues);
186
+ }
187
+ /**
188
+ * Build value headers for a dimension node (the actual dimension values like "CA", "TX", etc.)
189
+ */
190
+ function buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
191
+ // Find query results that contain this dimension
192
+ // Pass parentValues to filter values to only those that exist under parent context
193
+ const dimValues = extractDimensionValues(node.name, plan, results, axis, currentPath, parentValues);
194
+ const headers = [];
195
+ for (const value of dimValues) {
196
+ const valuePath = [
197
+ ...currentPath,
198
+ { type: 'dimension', name: node.name },
199
+ ];
200
+ // Create updated parent values including this dimension's value
201
+ const childParentValues = new Map(parentValues);
202
+ childParentValues.set(node.name, value);
203
+ // Recursively build child headers with updated parent context
204
+ const children = node.child
205
+ ? buildHeaderNodes(node.child, plan, results, axis, valuePath, depth + 1, childParentValues)
206
+ : [];
207
+ // Calculate span (how many leaf descendants)
208
+ const span = children.length > 0
209
+ ? children.reduce((sum, c) => sum + c.span, 0)
210
+ : 1;
211
+ headers.push({
212
+ type: 'dimension',
213
+ dimension: node.name,
214
+ value: String(value),
215
+ label: node.label,
216
+ span,
217
+ depth,
218
+ children: children.length > 0 ? children : undefined,
219
+ path: valuePath,
220
+ });
221
+ }
222
+ return headers;
223
+ }
224
+ /**
225
+ * Build headers for a total node.
226
+ */
227
+ function buildTotalHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
228
+ const totalPath = [
229
+ ...currentPath,
230
+ { type: 'total', label: node.label },
231
+ ];
232
+ // Recursively build child headers if any
233
+ const children = node.child
234
+ ? buildHeaderNodes(node.child, plan, results, axis, totalPath, depth + 1, parentValues)
235
+ : [];
236
+ const span = children.length > 0
237
+ ? children.reduce((sum, c) => sum + c.span, 0)
238
+ : 1;
239
+ return [{
240
+ type: 'total',
241
+ value: node.label ?? 'Total',
242
+ label: node.label,
243
+ span,
244
+ depth,
245
+ children: children.length > 0 ? children : undefined,
246
+ path: totalPath,
247
+ }];
248
+ }
249
+ /**
250
+ * Build headers for sibling nodes.
251
+ *
252
+ * For multiple dimension siblings (like `gender | state`), we add a sibling-label
253
+ * header to indicate which dimension the values belong to.
254
+ *
255
+ * For single dimension with totals (like `gender | ALL`), we DON'T add sibling-labels
256
+ * since the total is just an extension of the dimension, not a separate section.
257
+ */
258
+ function buildSiblingHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
259
+ const allHeaders = [];
260
+ // Check if all children are aggregates (regular or percentage)
261
+ const allAggregates = node.children.every(child => child.nodeType === 'aggregate' || child.nodeType === 'percentageAggregate');
262
+ // Count dimension children - only use sibling-labels when there are 2+ dimensions
263
+ // A single dimension with totals (dim | ALL) doesn't need sibling-labels
264
+ const dimensionChildren = node.children.filter(child => child.nodeType === 'dimension');
265
+ const hasMultipleDimensionSiblings = dimensionChildren.length >= 2;
266
+ for (let i = 0; i < node.children.length; i++) {
267
+ const child = node.children[i];
268
+ const siblingPath = [
269
+ ...currentPath,
270
+ { type: 'sibling', index: i },
271
+ ];
272
+ // Special handling for aggregate siblings: always create headers
273
+ if (allAggregates && child.nodeType === 'aggregate') {
274
+ const aggNode = child;
275
+ const aggName = `${aggNode.measure}_${aggNode.aggregation}`;
276
+ const displayValue = aggNode.label ?? formatAggregateName(aggNode.measure, aggNode.aggregation);
277
+ allHeaders.push({
278
+ type: 'dimension',
279
+ dimension: '_aggregate',
280
+ value: displayValue,
281
+ label: aggNode.label,
282
+ span: 1,
283
+ depth,
284
+ path: [...siblingPath, { type: 'aggregate', name: aggName }],
285
+ });
286
+ }
287
+ else if (allAggregates && child.nodeType === 'percentageAggregate') {
288
+ // Handle percentage aggregate siblings
289
+ const pctNode = child;
290
+ const aggName = `${pctNode.measure ?? ''}_${pctNode.aggregation}_pct`;
291
+ const displayValue = pctNode.label ?? formatAggregateName(pctNode.measure ?? 'count', pctNode.aggregation);
292
+ allHeaders.push({
293
+ type: 'dimension',
294
+ dimension: '_aggregate',
295
+ value: displayValue,
296
+ label: pctNode.label,
297
+ span: 1,
298
+ depth,
299
+ path: [...siblingPath, { type: 'aggregate', name: aggName }],
300
+ });
301
+ }
302
+ else if (child.nodeType === 'dimension') {
303
+ // Check if we need a sibling-label for this dimension
304
+ const dimNode = child;
305
+ const hasCustomLabel = dimNode.label !== undefined && dimNode.label !== '';
306
+ // Create sibling-label wrapper when:
307
+ // 1. Multiple dimension siblings (always need labels to distinguish them)
308
+ // 2. Single dimension with custom label (need to show the custom label)
309
+ // Skip sibling-label when:
310
+ // - suppressLabel is true (label explicitly set to "")
311
+ // - Single dimension without custom label (just show values directly)
312
+ const needsSiblingLabel = (hasMultipleDimensionSiblings || hasCustomLabel) && !dimNode.suppressLabel;
313
+ if (dimNode.suppressLabel || !needsSiblingLabel) {
314
+ // Build dimension value headers directly at current depth (no sibling-label wrapper)
315
+ const valueHeaders = buildDimensionValueHeaders(dimNode, plan, results, axis, siblingPath, depth, parentValues);
316
+ allHeaders.push(...valueHeaders);
317
+ }
318
+ else {
319
+ const dimensionLabel = dimNode.label ?? dimNode.name;
320
+ // Build child headers at depth+1 (under the sibling label)
321
+ const childHeaders = buildHeaderNodes(child, plan, results, axis, siblingPath, depth + 1, // Increased depth for children
322
+ parentValues);
323
+ // Calculate span from children
324
+ const span = childHeaders.length > 0
325
+ ? childHeaders.reduce((sum, c) => sum + c.span, 0)
326
+ : 1;
327
+ // Create the sibling-label header with children
328
+ allHeaders.push({
329
+ type: 'sibling-label',
330
+ dimension: dimNode.name,
331
+ value: dimensionLabel,
332
+ label: dimNode.label,
333
+ span,
334
+ depth,
335
+ children: childHeaders.length > 0 ? childHeaders : undefined,
336
+ path: siblingPath,
337
+ });
338
+ }
339
+ }
340
+ else {
341
+ // Other cases (totals, etc.) - pass through as before
342
+ const childHeaders = buildHeaderNodes(child, plan, results, axis, siblingPath, depth, parentValues);
343
+ allHeaders.push(...childHeaders);
344
+ }
345
+ }
346
+ return allHeaders;
347
+ }
348
+ /**
349
+ * Format an aggregate name for display (e.g., "births sum" from "births", "sum")
350
+ */
351
+ function formatAggregateName(measure, aggregation) {
352
+ // For count/n without a measure, just return the aggregation name
353
+ // This handles cases like standalone "count" or "n"
354
+ if (!measure || measure === '__pending__') {
355
+ return aggregation === 'count' ? 'N' : aggregation;
356
+ }
357
+ // For count with a measure (e.g., income.count), just return "N" since
358
+ // count doesn't really bind to a measure in Malloy
359
+ if (aggregation === 'count') {
360
+ return 'N';
361
+ }
362
+ return `${measure} ${aggregation}`;
363
+ }
364
+ /**
365
+ * Extract unique dimension values from query results.
366
+ * Values are sorted alphabetically by default, unless:
367
+ * - The dimension has a limit (data order preserved from query)
368
+ * - The dimension has an explicit order (data order preserved from query)
369
+ *
370
+ * @param parentValues Optional context: parent dimension values to filter by.
371
+ * For example, when building name headers under state=CA, only return names
372
+ * that exist in the data where state=CA.
373
+ */
374
+ function extractDimensionValues(dimension, plan, results, axis, currentPath, parentValues = new Map()) {
375
+ // Use an array to preserve order (for cases with explicit order/limit)
376
+ // and a Set to track uniqueness
377
+ const valuesArray = [];
378
+ const seenValues = new Set();
379
+ // Check if this dimension has an explicit order or limit
380
+ // If so, we should preserve the data order from the query
381
+ let hasExplicitOrder = false;
382
+ let hasLimit = false;
383
+ for (const query of plan.queries) {
384
+ // Collect all groupings to check (including additionalColVariants for merged queries)
385
+ const groupingsToCheck = [];
386
+ if (axis === 'row') {
387
+ groupingsToCheck.push(query.rowGroupings);
388
+ }
389
+ else {
390
+ groupingsToCheck.push(query.colGroupings);
391
+ if (query.additionalColVariants) {
392
+ for (const variant of query.additionalColVariants) {
393
+ groupingsToCheck.push(variant.colGroupings);
394
+ }
395
+ }
396
+ }
397
+ for (const groupings of groupingsToCheck) {
398
+ const grouping = groupings.find(g => g.dimension === dimension);
399
+ if (grouping) {
400
+ if (grouping.order?.direction) {
401
+ hasExplicitOrder = true;
402
+ }
403
+ if (grouping.limit) {
404
+ hasLimit = true;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ const preserveDataOrder = hasExplicitOrder || hasLimit;
410
+ // Look through all queries that have this dimension in the right axis
411
+ for (const query of plan.queries) {
412
+ const queryResults = results.get(query.id);
413
+ if (!queryResults)
414
+ continue;
415
+ if (axis === 'row') {
416
+ // For row dimensions, check rowGroupings
417
+ if (query.rowGroupings.some(g => g.dimension === dimension)) {
418
+ extractValuesFromDataOrdered(queryResults, dimension, valuesArray, seenValues, query.rowGroupings, 0, parentValues);
419
+ }
420
+ }
421
+ else {
422
+ // For column dimensions, check primary colGroupings AND additionalColVariants
423
+ // This handles merged queries where some dimensions are in additionalColVariants
424
+ const allColGroupings = [query.colGroupings];
425
+ if (query.additionalColVariants) {
426
+ for (const variant of query.additionalColVariants) {
427
+ allColGroupings.push(variant.colGroupings);
428
+ }
429
+ }
430
+ for (const colGroupings of allColGroupings) {
431
+ if (colGroupings.some(g => g.dimension === dimension)) {
432
+ // For columns, we need to navigate through row nesting to find pivot structure
433
+ extractColValuesFromDataOrdered(queryResults, dimension, valuesArray, seenValues, colGroupings, query.rowGroupings, parentValues);
434
+ }
435
+ }
436
+ }
437
+ }
438
+ // If we should preserve data order (explicit order or limit), return as-is
439
+ if (preserveDataOrder) {
440
+ return valuesArray;
441
+ }
442
+ // Otherwise, sort for consistent display order
443
+ // Default is alphabetical/numerical ascending
444
+ valuesArray.sort((a, b) => {
445
+ // Handle mixed string/number sorting
446
+ if (typeof a === 'number' && typeof b === 'number') {
447
+ return a - b; // Numeric ascending
448
+ }
449
+ return String(a).localeCompare(String(b)); // Alphabetic ascending
450
+ });
451
+ return valuesArray;
452
+ }
453
+ /**
454
+ * Extract dimension values from query result data (for row dimensions).
455
+ * Handles both flat structure (multiple dims in same group_by) and
456
+ * nested structure (dims in separate nested levels).
457
+ *
458
+ * Also handles inverted queries where row dimensions might be nested
459
+ * inside column structures (e.g., for global column limits).
460
+ *
461
+ * @param parentValues Context: only extract values from rows where parent dimensions match
462
+ */
463
+ function extractValuesFromData(data, dimension, values, groupings, depth = 0, parentValues = new Map()) {
464
+ if (!data || data.length === 0)
465
+ return;
466
+ // Find the grouping for this dimension to get its label (if any)
467
+ const grouping = groupings.find(g => g.dimension === dimension);
468
+ // In Malloy output, labeled dimensions use the label as the column name
469
+ const dataKey = grouping?.label ?? dimension;
470
+ for (const row of data) {
471
+ // Check if this row matches all parent dimension constraints
472
+ let matchesParent = true;
473
+ for (const [parentDim, parentVal] of parentValues) {
474
+ // Find the grouping for parent dimension to get its label
475
+ const parentGrouping = groupings.find(g => g.dimension === parentDim);
476
+ const parentDataKey = parentGrouping?.label ?? parentDim;
477
+ const rowVal = row[parentDataKey] ?? row[parentDim];
478
+ // Handle "(null)" matching: if parentVal is "(null)", match against null in data
479
+ const matches = rowVal === undefined ||
480
+ rowVal === parentVal ||
481
+ (parentVal === '(null)' && rowVal === null);
482
+ if (!matches) {
483
+ matchesParent = false;
484
+ break;
485
+ }
486
+ }
487
+ if (!matchesParent)
488
+ continue;
489
+ // First, check if the dimension exists directly on this row (flat structure)
490
+ // This handles cases where multiple dimensions are in the same group_by
491
+ // Check both the label (if present) and the original dimension name
492
+ let value = row[dataKey];
493
+ if (value === undefined) {
494
+ value = row[dimension];
495
+ }
496
+ // Include null values as "(null)" to ensure limit counts match displayed rows
497
+ if (value === null) {
498
+ value = '(null)';
499
+ }
500
+ if (value !== undefined) {
501
+ values.add(value);
502
+ }
503
+ // Check ALL nested structures, not just those in the expected groupings
504
+ // This handles inverted queries where row dims might be inside column nesting
505
+ for (const key of Object.keys(row)) {
506
+ if (key.startsWith('by_') && Array.isArray(row[key])) {
507
+ extractValuesFromData(row[key], dimension, values, groupings, depth + 1, parentValues);
508
+ }
509
+ }
510
+ }
511
+ }
512
+ /**
513
+ * Extract dimension values from column pivot structure.
514
+ * Must navigate through row nesting to find column pivots.
515
+ *
516
+ * Also handles flat queries where column dimensions are at the top level
517
+ * (no nesting) - same as row dimensions.
518
+ *
519
+ * @param parentValues Context: only extract values where parent dimensions match
520
+ */
521
+ function extractColValuesFromData(data, dimension, values, colGroupings, rowGroupings = [], parentValues = new Map()) {
522
+ if (!data || data.length === 0 || colGroupings.length === 0)
523
+ return;
524
+ // Find the grouping for this dimension to get its label (if any)
525
+ const grouping = colGroupings.find(g => g.dimension === dimension);
526
+ const dataKey = grouping?.label ?? dimension;
527
+ // First, check if this is a flat query (column dims at top level)
528
+ // If the first row has the dimension directly, it's a flat query
529
+ if (data.length > 0 && (data[0][dataKey] !== undefined || data[0][dimension] !== undefined)) {
530
+ // Flat query - extract values directly from top level
531
+ for (const row of data) {
532
+ let value = row[dataKey];
533
+ if (value === undefined) {
534
+ value = row[dimension];
535
+ }
536
+ // Include null values as "(null)" to ensure limit counts match displayed rows
537
+ if (value === null) {
538
+ value = '(null)';
539
+ }
540
+ if (value !== undefined) {
541
+ values.add(value);
542
+ }
543
+ }
544
+ return;
545
+ }
546
+ // Navigate through row structure to find leaf rows, then extract column values
547
+ for (const row of data) {
548
+ navigateToColPivots(row, dimension, values, colGroupings, rowGroupings, 0, parentValues);
549
+ }
550
+ }
551
+ /**
552
+ * Navigate through row nesting to reach column pivot data.
553
+ *
554
+ * Handles both cases:
555
+ * 1. Row dims at top level: { state: 'CA', by_gender: [...] }
556
+ * 2. Wrapped in nests: { by_state: [{ state: 'CA', by_gender: [...] }] }
557
+ *
558
+ * @param parentValues Context: parent dimension values to filter by
559
+ */
560
+ function navigateToColPivots(row, dimension, values, colGroupings, rowGroupings, rowDepth, parentValues = new Map()) {
561
+ // First, check if we need to navigate INTO the current row dimension's nest
562
+ // This handles the case where data is wrapped: { by_state: [...] }
563
+ if (rowDepth < rowGroupings.length) {
564
+ const currentRowDim = rowGroupings[rowDepth]?.dimension;
565
+ const currentNestedKey = `by_${currentRowDim}`;
566
+ // Check if this row dimension value exists directly on the row
567
+ const hasValueDirectly = row[currentRowDim] !== undefined;
568
+ // Or if we need to navigate into its nest first
569
+ if (!hasValueDirectly && row[currentNestedKey] && Array.isArray(row[currentNestedKey])) {
570
+ // Navigate into the current dimension's nest
571
+ for (const nestedRow of row[currentNestedKey]) {
572
+ navigateToColPivots(nestedRow, dimension, values, colGroupings, rowGroupings, rowDepth, parentValues);
573
+ }
574
+ return;
575
+ }
576
+ }
577
+ // Check if there's more row nesting to navigate
578
+ if (rowDepth < rowGroupings.length - 1) {
579
+ const nextRowDim = rowGroupings[rowDepth + 1]?.dimension;
580
+ const nestedRowKey = `by_${nextRowDim}`;
581
+ const nestedRows = row[nestedRowKey];
582
+ if (nestedRows && Array.isArray(nestedRows)) {
583
+ for (const nestedRow of nestedRows) {
584
+ navigateToColPivots(nestedRow, dimension, values, colGroupings, rowGroupings, rowDepth + 1, parentValues);
585
+ }
586
+ return;
587
+ }
588
+ }
589
+ // At leaf of row structure - now extract column values
590
+ extractColValuesFromRow(row, dimension, values, colGroupings, 0, parentValues);
591
+ }
592
+ /**
593
+ * Extract column dimension values from a row at leaf level.
594
+ *
595
+ * Handles the case where the first column dimension is at the top level
596
+ * of the row (not nested), which occurs in inverted queries.
597
+ *
598
+ * @param parentValues Context: parent column dimension values to filter by
599
+ */
600
+ function extractColValuesFromRow(row, dimension, values, colGroupings, depth, parentValues = new Map()) {
601
+ if (depth >= colGroupings.length)
602
+ return;
603
+ const currentDim = colGroupings[depth];
604
+ const nestedKey = `by_${currentDim.dimension}`;
605
+ const nested = row[nestedKey];
606
+ // Check if the current dimension is at the TOP LEVEL of the row (not nested)
607
+ // This happens when the first column dimension is at the outer level in inverted queries
608
+ const topLevelDataKey = currentDim.label ?? currentDim.dimension;
609
+ const topLevelValue = row[topLevelDataKey] ?? row[currentDim.dimension];
610
+ if (topLevelValue !== undefined) {
611
+ // First column dimension is at top level
612
+ // Include null values as "(null)" to ensure limit counts match displayed rows
613
+ let displayTopValue = topLevelValue;
614
+ if (displayTopValue === null) {
615
+ displayTopValue = '(null)';
616
+ }
617
+ // Check parent constraint
618
+ const parentVal = parentValues.get(currentDim.dimension);
619
+ if (parentVal !== undefined && displayTopValue !== parentVal) {
620
+ return; // Parent constraint not matched
621
+ }
622
+ if (currentDim.dimension === dimension) {
623
+ // This is the dimension we're extracting - add the top level value
624
+ values.add(displayTopValue);
625
+ }
626
+ // Look for the NEXT dimension's nested key
627
+ if (depth + 1 < colGroupings.length) {
628
+ const nextDim = colGroupings[depth + 1];
629
+ const nextNestedKey = `by_${nextDim.dimension}`;
630
+ const nextNested = row[nextNestedKey];
631
+ if (nextNested && Array.isArray(nextNested)) {
632
+ // Recurse into next dimension's nesting, but start at depth+1
633
+ for (const nestedRow of nextNested) {
634
+ extractColValuesFromRow(nestedRow, dimension, values, colGroupings, depth + 1, parentValues);
635
+ }
636
+ }
637
+ }
638
+ return;
639
+ }
640
+ // Standard nested case: current dimension is in a by_X array
641
+ if (!nested || !Array.isArray(nested))
642
+ return;
643
+ for (const colRow of nested) {
644
+ // Check if parent column dimensions match
645
+ // For state * name, when extracting names for state=CA,
646
+ // we check if this row's state matches CA
647
+ let parentMatch = true;
648
+ const dataKey = currentDim.label ?? currentDim.dimension;
649
+ const colVal = colRow[dataKey] ?? colRow[currentDim.dimension];
650
+ // Check if this row's current dimension matches the parent value constraint
651
+ const parentVal = parentValues.get(currentDim.dimension);
652
+ if (parentVal !== undefined && colVal !== parentVal) {
653
+ parentMatch = false;
654
+ }
655
+ if (!parentMatch)
656
+ continue;
657
+ if (currentDim.dimension === dimension) {
658
+ // This is the dimension we're extracting values for
659
+ // Include null values as "(null)" to ensure limit counts match displayed rows
660
+ let displayVal = colVal;
661
+ if (displayVal === null) {
662
+ displayVal = '(null)';
663
+ }
664
+ if (displayVal !== undefined) {
665
+ values.add(displayVal);
666
+ }
667
+ }
668
+ // Continue to deeper column levels
669
+ extractColValuesFromRow(colRow, dimension, values, colGroupings, depth + 1, parentValues);
670
+ }
671
+ }
672
+ // ---
673
+ // ORDER-PRESERVING VALUE EXTRACTION
674
+ // ---
675
+ /**
676
+ * Extract dimension values from query result data, preserving order.
677
+ * Same as extractValuesFromData but uses array + Set for order preservation.
678
+ */
679
+ function extractValuesFromDataOrdered(data, dimension, valuesArray, seenValues, groupings, depth = 0, parentValues = new Map()) {
680
+ if (!data || data.length === 0)
681
+ return;
682
+ const grouping = groupings.find(g => g.dimension === dimension);
683
+ const dataKey = grouping?.label ?? dimension;
684
+ for (const row of data) {
685
+ let matchesParent = true;
686
+ for (const [parentDim, parentVal] of parentValues) {
687
+ const parentGrouping = groupings.find(g => g.dimension === parentDim);
688
+ const parentDataKey = parentGrouping?.label ?? parentDim;
689
+ const rowVal = row[parentDataKey] ?? row[parentDim];
690
+ // Handle "(null)" matching: if parentVal is "(null)", match against null in data
691
+ const matches = rowVal === undefined ||
692
+ rowVal === parentVal ||
693
+ (parentVal === '(null)' && rowVal === null);
694
+ if (!matches) {
695
+ matchesParent = false;
696
+ break;
697
+ }
698
+ }
699
+ if (!matchesParent)
700
+ continue;
701
+ let value = row[dataKey];
702
+ if (value === undefined) {
703
+ value = row[dimension];
704
+ }
705
+ // Include null values as "(null)" to ensure limit counts match displayed rows
706
+ if (value === null) {
707
+ value = '(null)';
708
+ }
709
+ if (value !== undefined && !seenValues.has(value)) {
710
+ seenValues.add(value);
711
+ valuesArray.push(value);
712
+ }
713
+ for (const key of Object.keys(row)) {
714
+ if (key.startsWith('by_') && Array.isArray(row[key])) {
715
+ extractValuesFromDataOrdered(row[key], dimension, valuesArray, seenValues, groupings, depth + 1, parentValues);
716
+ }
717
+ }
718
+ }
719
+ }
720
+ /**
721
+ * Extract dimension values from column pivot structure, preserving order.
722
+ * Same as extractColValuesFromData but uses array + Set for order preservation.
723
+ */
724
+ function extractColValuesFromDataOrdered(data, dimension, valuesArray, seenValues, colGroupings, rowGroupings = [], parentValues = new Map()) {
725
+ if (!data || data.length === 0 || colGroupings.length === 0)
726
+ return;
727
+ const grouping = colGroupings.find(g => g.dimension === dimension);
728
+ const dataKey = grouping?.label ?? dimension;
729
+ // First, check if this is a flat query (column dims at top level)
730
+ if (data.length > 0 && (data[0][dataKey] !== undefined || data[0][dimension] !== undefined)) {
731
+ for (const row of data) {
732
+ let value = row[dataKey];
733
+ if (value === undefined) {
734
+ value = row[dimension];
735
+ }
736
+ // Include null values as "(null)" to ensure limit counts match displayed rows
737
+ if (value === null) {
738
+ value = '(null)';
739
+ }
740
+ if (value !== undefined && !seenValues.has(value)) {
741
+ seenValues.add(value);
742
+ valuesArray.push(value);
743
+ }
744
+ }
745
+ return;
746
+ }
747
+ // Navigate through row structure to find leaf rows, then extract column values
748
+ for (const row of data) {
749
+ navigateToColPivotsOrdered(row, dimension, valuesArray, seenValues, colGroupings, rowGroupings, 0, parentValues);
750
+ }
751
+ }
752
+ /**
753
+ * Navigate through row nesting to reach column pivot data, preserving order.
754
+ */
755
+ function navigateToColPivotsOrdered(row, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth, parentValues = new Map()) {
756
+ if (rowDepth < rowGroupings.length) {
757
+ const currentRowDim = rowGroupings[rowDepth]?.dimension;
758
+ const currentNestedKey = `by_${currentRowDim}`;
759
+ const hasValueDirectly = row[currentRowDim] !== undefined;
760
+ if (!hasValueDirectly && row[currentNestedKey] && Array.isArray(row[currentNestedKey])) {
761
+ for (const nestedRow of row[currentNestedKey]) {
762
+ navigateToColPivotsOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth, parentValues);
763
+ }
764
+ return;
765
+ }
766
+ }
767
+ if (rowDepth < rowGroupings.length - 1) {
768
+ const nextRowDim = rowGroupings[rowDepth + 1]?.dimension;
769
+ const nestedRowKey = `by_${nextRowDim}`;
770
+ const nestedRows = row[nestedRowKey];
771
+ if (nestedRows && Array.isArray(nestedRows)) {
772
+ for (const nestedRow of nestedRows) {
773
+ navigateToColPivotsOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth + 1, parentValues);
774
+ }
775
+ return;
776
+ }
777
+ }
778
+ extractColValuesFromRowOrdered(row, dimension, valuesArray, seenValues, colGroupings, 0, parentValues);
779
+ }
780
+ /**
781
+ * Extract column dimension values from a row at leaf level, preserving order.
782
+ *
783
+ * Handles the case where the first column dimension is at the top level
784
+ * of the row (not nested), which occurs in inverted queries.
785
+ */
786
+ function extractColValuesFromRowOrdered(row, dimension, valuesArray, seenValues, colGroupings, depth, parentValues = new Map()) {
787
+ if (depth >= colGroupings.length)
788
+ return;
789
+ const currentDim = colGroupings[depth];
790
+ const nestedKey = `by_${currentDim.dimension}`;
791
+ const nested = row[nestedKey];
792
+ // Check if the current dimension is at the TOP LEVEL of the row (not nested)
793
+ // This happens when the first column dimension is at the outer level in inverted queries
794
+ const topLevelDataKey = currentDim.label ?? currentDim.dimension;
795
+ const topLevelValue = row[topLevelDataKey] ?? row[currentDim.dimension];
796
+ if (topLevelValue !== undefined) {
797
+ // First column dimension is at top level
798
+ // Include null values as "(null)" to ensure limit counts match displayed rows
799
+ let displayTopValue = topLevelValue;
800
+ if (displayTopValue === null) {
801
+ displayTopValue = '(null)';
802
+ }
803
+ // Check parent constraint
804
+ const parentVal = parentValues.get(currentDim.dimension);
805
+ if (parentVal !== undefined && displayTopValue !== parentVal) {
806
+ return; // Parent constraint not matched
807
+ }
808
+ if (currentDim.dimension === dimension) {
809
+ // This is the dimension we're extracting - add the top level value
810
+ if (!seenValues.has(displayTopValue)) {
811
+ seenValues.add(displayTopValue);
812
+ valuesArray.push(displayTopValue);
813
+ }
814
+ }
815
+ // Look for the NEXT dimension's nested key
816
+ if (depth + 1 < colGroupings.length) {
817
+ const nextDim = colGroupings[depth + 1];
818
+ const nextNestedKey = `by_${nextDim.dimension}`;
819
+ const nextNested = row[nextNestedKey];
820
+ if (nextNested && Array.isArray(nextNested)) {
821
+ // Recurse into next dimension's nesting, but start at depth+1
822
+ for (const nestedRow of nextNested) {
823
+ extractColValuesFromRowOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, depth + 1, parentValues);
824
+ }
825
+ }
826
+ }
827
+ return;
828
+ }
829
+ // Standard nested case: current dimension is in a by_X array
830
+ if (!nested || !Array.isArray(nested))
831
+ return;
832
+ for (const colRow of nested) {
833
+ let parentMatch = true;
834
+ const dataKey = currentDim.label ?? currentDim.dimension;
835
+ const colVal = colRow[dataKey] ?? colRow[currentDim.dimension];
836
+ const parentVal = parentValues.get(currentDim.dimension);
837
+ if (parentVal !== undefined && colVal !== parentVal) {
838
+ parentMatch = false;
839
+ }
840
+ if (!parentMatch)
841
+ continue;
842
+ if (currentDim.dimension === dimension) {
843
+ // Include null values as "(null)" to ensure limit counts match displayed rows
844
+ let displayColVal = colVal;
845
+ if (displayColVal === null) {
846
+ displayColVal = '(null)';
847
+ }
848
+ if (displayColVal !== undefined && !seenValues.has(displayColVal)) {
849
+ seenValues.add(displayColVal);
850
+ valuesArray.push(displayColVal);
851
+ }
852
+ }
853
+ extractColValuesFromRowOrdered(colRow, dimension, valuesArray, seenValues, colGroupings, depth + 1, parentValues);
854
+ }
855
+ }
856
+ // ---
857
+ // CELL LOOKUP BUILDER
858
+ // ---
859
+ /**
860
+ * A value-based key for cell lookup.
861
+ * Combines all dimensions into a single sorted key (axis-independent).
862
+ * This allows lookups to work regardless of whether a dimension is on rows or columns.
863
+ */
864
+ function makeCellKey(rowValues, colValues) {
865
+ // Combine all dimension values, ignoring which axis they're on
866
+ const allEntries = [
867
+ ...Array.from(rowValues.entries()),
868
+ ...Array.from(colValues.entries()),
869
+ ];
870
+ return allEntries
871
+ .sort((a, b) => a[0].localeCompare(b[0]))
872
+ .map(([k, v]) => `${k}=${v}`)
873
+ .join('|');
874
+ }
875
+ /**
876
+ * Build a cell lookup function from query results.
877
+ *
878
+ * Key insight: The lookup uses dimension VALUES, not structural positions.
879
+ * We now use axis-independent keys so lookups work regardless of which axis
880
+ * a dimension is on.
881
+ *
882
+ * @param invertedQueries Set of query IDs that have inverted axes (column dim is outer in Malloy)
883
+ * @param flatQueries Set of query IDs that use flat structure (all dims in single group_by)
884
+ */
885
+ function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new Set()) {
886
+ // Build an index: cellKey → aggregateName → value
887
+ const cellIndex = new Map();
888
+ // Index all query results by plan query ID
889
+ // Since we now use a unified QueryPlan system, IDs should match directly
890
+ const DEBUG = process.env.DEBUG_GRID === 'true';
891
+ for (const query of plan.queries) {
892
+ const queryData = results.get(query.id);
893
+ if (!queryData || queryData.length === 0) {
894
+ if (DEBUG) {
895
+ console.log(` No data for query ${query.id}`);
896
+ }
897
+ continue;
898
+ }
899
+ const isInverted = invertedQueries.has(query.id);
900
+ const isFlatQuery = flatQueries.has(query.id);
901
+ if (DEBUG) {
902
+ console.log(` Indexing ${query.id}: ${queryData.length} rows, inverted=${isInverted}, flat=${isFlatQuery}`);
903
+ }
904
+ indexQueryResults(queryData, query, cellIndex, spec.aggregates, isInverted, isFlatQuery);
905
+ }
906
+ // Debug: show all indexed keys
907
+ if (DEBUG) {
908
+ console.log(` Total cell keys indexed: ${cellIndex.size}`);
909
+ const keys = Array.from(cellIndex.keys()).slice(0, 5);
910
+ console.log(` Sample keys: ${keys.join(', ')}`);
911
+ }
912
+ return {
913
+ get(rowValues, colValues, aggregate) {
914
+ const cellKey = makeCellKey(rowValues, colValues);
915
+ const cellData = cellIndex.get(cellKey);
916
+ // Determine which aggregate to return
917
+ const aggName = aggregate ?? spec.aggregates[0]?.name ?? '';
918
+ const agg = spec.aggregates.find(a => a.name === aggName);
919
+ if (!cellData) {
920
+ if (DEBUG) {
921
+ console.log(` Cell miss: ${cellKey}`);
922
+ }
923
+ return {
924
+ raw: null,
925
+ formatted: '',
926
+ aggregate: aggName,
927
+ pathDescription: cellKey,
928
+ };
929
+ }
930
+ const value = cellData.get(aggName) ?? null;
931
+ return {
932
+ raw: value,
933
+ formatted: formatValue(value, agg),
934
+ aggregate: aggName,
935
+ pathDescription: cellKey,
936
+ };
937
+ }
938
+ };
939
+ }
940
+ /**
941
+ * Index query results into the cell lookup structure.
942
+ *
943
+ * @param isInverted When true, the Malloy query has swapped axes:
944
+ * - Column dimension is outer (for global limit)
945
+ * - Row dimension is nested
946
+ * We swap the groupings interpretation to match.
947
+ * @param isFlatQuery When true, all dimensions are at the same level (no nesting)
948
+ */
949
+ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = false, isFlatQuery = false) {
950
+ // Handle flat queries: all dimensions are at top level, no nesting
951
+ if (isFlatQuery) {
952
+ indexFlatQueryResults(data, query, cellIndex, aggregates);
953
+ return;
954
+ }
955
+ // When inverted, the Malloy query structure is:
956
+ // - Outer: column dimension (appears in data as top-level property)
957
+ // - Nested: row dimension (appears as by_X nested array)
958
+ // But we still want to build keys based on LOGICAL row/col groupings.
959
+ // So we tell flattenAndIndex that what it sees as "row" data is actually "col".
960
+ const malloyRowGroupings = isInverted ? query.colGroupings : query.rowGroupings;
961
+ const malloyColGroupings = isInverted ? query.rowGroupings : query.colGroupings;
962
+ // Flatten nested data and build value-based keys for primary column variant
963
+ flattenAndIndex(data, malloyRowGroupings, malloyColGroupings, aggregates, new Map(), // values for malloy outer (will be mapped to correct axis)
964
+ new Map(), // values for malloy nested (will be mapped to correct axis)
965
+ cellIndex, 0, isInverted, query.rowGroupings, // logical row groupings
966
+ query.colGroupings // logical col groupings
967
+ );
968
+ // Handle merged queries with additional column variants
969
+ // Each variant has its own nests (e.g., by_gender vs by_sector_label)
970
+ // that need to be indexed separately
971
+ if (query.additionalColVariants) {
972
+ for (const variant of query.additionalColVariants) {
973
+ const variantColGroupings = isInverted ? query.rowGroupings : variant.colGroupings;
974
+ flattenAndIndex(data, malloyRowGroupings, variantColGroupings, aggregates, new Map(), new Map(), cellIndex, 0, isInverted, query.rowGroupings, variant.colGroupings);
975
+ }
976
+ }
977
+ }
978
+ /**
979
+ * Index flat query results where all dimensions are at the top level.
980
+ *
981
+ * Flat queries have structure: { dim1: val1, dim2: val2, ..., agg: value }
982
+ * No nested by_X arrays - all dimensions are directly on each row.
983
+ */
984
+ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
985
+ for (const row of data) {
986
+ // Extract all row dimension values
987
+ const rowValues = new Map();
988
+ for (const g of query.rowGroupings) {
989
+ const dataKey = g.label ?? g.dimension;
990
+ let value = row[dataKey];
991
+ if (value === undefined) {
992
+ value = row[g.dimension];
993
+ }
994
+ // Map null to "(null)" to match header extraction
995
+ if (value === null) {
996
+ value = '(null)';
997
+ }
998
+ if (value !== undefined) {
999
+ rowValues.set(g.dimension, value);
1000
+ }
1001
+ }
1002
+ // Extract all column dimension values
1003
+ const colValues = new Map();
1004
+ for (const g of query.colGroupings) {
1005
+ const dataKey = g.label ?? g.dimension;
1006
+ let value = row[dataKey];
1007
+ if (value === undefined) {
1008
+ value = row[g.dimension];
1009
+ }
1010
+ // Map null to "(null)" to match header extraction
1011
+ if (value === null) {
1012
+ value = '(null)';
1013
+ }
1014
+ if (value !== undefined) {
1015
+ colValues.set(g.dimension, value);
1016
+ }
1017
+ }
1018
+ // Index aggregate values using the combined row/col values
1019
+ indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
1020
+ }
1021
+ }
1022
+ /**
1023
+ * Recursively flatten nested data and index by dimension values.
1024
+ *
1025
+ * @param malloyOuterGroupings The groupings that are at the outer level in Malloy data
1026
+ * @param malloyNestedGroupings The groupings that are nested (by_X) in Malloy data
1027
+ * @param isInverted When true, malloy outer = logical cols, malloy nested = logical rows
1028
+ * @param logicalRowGroupings The actual row groupings for cell key building
1029
+ * @param logicalColGroupings The actual col groupings for cell key building
1030
+ */
1031
+ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings) {
1032
+ for (const row of data) {
1033
+ // Build outer values - collect ALL outer dimension values from the current row
1034
+ // This handles the case where multiple dimensions are in the same group_by
1035
+ const currentOuterValues = new Map(baseOuterValues);
1036
+ // Collect all outer dimension values that exist on this row
1037
+ for (let i = outerDepth; i < malloyOuterGroupings.length; i++) {
1038
+ const g = malloyOuterGroupings[i];
1039
+ const dim = g.dimension;
1040
+ // Use label as data key if present, otherwise use dimension name
1041
+ const dataKey = g.label ?? dim;
1042
+ let value = row[dataKey];
1043
+ if (value === undefined) {
1044
+ value = row[dim];
1045
+ }
1046
+ // Map null to "(null)" to match header extraction
1047
+ if (value === null) {
1048
+ value = '(null)';
1049
+ }
1050
+ if (value !== undefined) {
1051
+ currentOuterValues.set(dim, value);
1052
+ }
1053
+ else {
1054
+ // Check for nested structure
1055
+ const nestedKey = `by_${dim}`;
1056
+ if (row[nestedKey] && Array.isArray(row[nestedKey])) {
1057
+ // This dimension is nested - recurse
1058
+ flattenAndIndex(row[nestedKey], malloyOuterGroupings, malloyNestedGroupings, aggregates, currentOuterValues, baseNestedValues, cellIndex, i, isInverted, logicalRowGroupings, logicalColGroupings);
1059
+ break; // Don't continue with this row, we recursed
1060
+ }
1061
+ }
1062
+ }
1063
+ // Handle nested pivots or direct aggregates
1064
+ // Only if we collected all outer dimensions (didn't break out for nesting)
1065
+ const hasAllOuterDims = malloyOuterGroupings.every(g => currentOuterValues.has(g.dimension) ||
1066
+ row[`by_${g.dimension}`]);
1067
+ if (!hasAllOuterDims) {
1068
+ continue; // Skipped this row because we recursed into nested data
1069
+ }
1070
+ if (malloyNestedGroupings.length > 0) {
1071
+ indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted);
1072
+ }
1073
+ else {
1074
+ // Direct aggregate values
1075
+ // When inverted: currentOuterValues = col values, baseNestedValues = row values
1076
+ // When normal: currentOuterValues = row values, baseNestedValues = col values
1077
+ if (isInverted) {
1078
+ indexAggregateValues(row, aggregates, baseNestedValues, // These are actually the row values (empty when no nesting)
1079
+ currentOuterValues, // These are actually the col values
1080
+ cellIndex);
1081
+ }
1082
+ else {
1083
+ indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex);
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Index column pivot values.
1090
+ *
1091
+ * @param isInverted When true, swap the interpretation:
1092
+ * - outerValues are actually column values (from outer malloy grouping)
1093
+ * - nestedValues are actually row values (from nested malloy grouping)
1094
+ */
1095
+ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false) {
1096
+ const currentDim = nestedGroupings[nestedDepth];
1097
+ const remainingDims = nestedGroupings.slice(nestedDepth);
1098
+ // First try the single dimension key
1099
+ let nestedKey = `by_${currentDim.dimension}`;
1100
+ let nested = row[nestedKey];
1101
+ // If not found, look for a combined key containing all remaining dimensions
1102
+ // This happens in inverted queries where row dims are grouped together
1103
+ if (!nested || !Array.isArray(nested)) {
1104
+ // Build combined key: by_dim1_dim2_...
1105
+ const combinedKey = 'by_' + remainingDims.map(g => g.dimension).join('_');
1106
+ nested = row[combinedKey];
1107
+ if (nested && Array.isArray(nested)) {
1108
+ // Found combined nest - extract ALL dimension values from each row
1109
+ for (const nestedRow of nested) {
1110
+ const currentNestedValues = new Map(baseNestedValues);
1111
+ // Extract all dimension values from this row
1112
+ for (const g of remainingDims) {
1113
+ const dataKey = g.label ?? g.dimension;
1114
+ let value = nestedRow[dataKey];
1115
+ if (value === undefined) {
1116
+ value = nestedRow[g.dimension];
1117
+ }
1118
+ // Map null to "(null)" to match header extraction
1119
+ if (value === null) {
1120
+ value = '(null)';
1121
+ }
1122
+ if (value !== undefined) {
1123
+ currentNestedValues.set(g.dimension, value);
1124
+ }
1125
+ }
1126
+ // This is the leaf level - index aggregate values
1127
+ if (isInverted) {
1128
+ indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
1129
+ outerValues, // These are actually col values
1130
+ cellIndex);
1131
+ }
1132
+ else {
1133
+ indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
1134
+ }
1135
+ }
1136
+ return;
1137
+ }
1138
+ // No matching nested key found
1139
+ return;
1140
+ }
1141
+ // Standard case: single dimension per nest level
1142
+ for (const nestedRow of nested) {
1143
+ // Use label as data key if present, otherwise use dimension name
1144
+ const dataKey = currentDim.label ?? currentDim.dimension;
1145
+ let nestedValue = nestedRow[dataKey];
1146
+ if (nestedValue === undefined) {
1147
+ nestedValue = nestedRow[currentDim.dimension];
1148
+ }
1149
+ // Map null to "(null)" to match header extraction
1150
+ if (nestedValue === null) {
1151
+ nestedValue = '(null)';
1152
+ }
1153
+ const currentNestedValues = new Map(baseNestedValues);
1154
+ if (nestedValue !== undefined) {
1155
+ currentNestedValues.set(currentDim.dimension, nestedValue);
1156
+ }
1157
+ if (nestedDepth + 1 < nestedGroupings.length) {
1158
+ // More nesting
1159
+ indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted);
1160
+ }
1161
+ else {
1162
+ // Leaf - index aggregate values
1163
+ // When inverted: outer = cols, nested = rows
1164
+ // When normal: outer = rows, nested = cols
1165
+ if (isInverted) {
1166
+ indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
1167
+ outerValues, // These are actually col values
1168
+ cellIndex);
1169
+ }
1170
+ else {
1171
+ indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Index aggregate values at a specific row/col value combination.
1178
+ */
1179
+ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
1180
+ const cellKey = makeCellKey(rowValues, colValues);
1181
+ let cellData = cellIndex.get(cellKey);
1182
+ if (!cellData) {
1183
+ cellData = new Map();
1184
+ cellIndex.set(cellKey, cellData);
1185
+ }
1186
+ for (const agg of aggregates) {
1187
+ const value = row[agg.name];
1188
+ if (value !== undefined) {
1189
+ cellData.set(agg.name, typeof value === 'number' ? value : null);
1190
+ }
1191
+ }
1192
+ }
1193
+ /**
1194
+ * Parse a custom format pattern into prefix, precision, and suffix.
1195
+ * Pattern syntax: 'prefix #.precision suffix'
1196
+ * Examples:
1197
+ * '$ #.2' → prefix='$ ', precision=2, suffix=''
1198
+ * '#.0 units' → prefix='', precision=0, suffix=' units'
1199
+ * '€ #.2 M' → prefix='€ ', precision=2, suffix=' M'
1200
+ * '# %' → prefix='', precision=undefined, suffix=' %'
1201
+ */
1202
+ function parseCustomFormatPattern(pattern) {
1203
+ // Find the # placeholder
1204
+ const hashIndex = pattern.indexOf('#');
1205
+ if (hashIndex === -1) {
1206
+ // No placeholder - treat entire pattern as suffix
1207
+ return { prefix: '', suffix: pattern, precision: undefined };
1208
+ }
1209
+ const prefix = pattern.substring(0, hashIndex);
1210
+ let remainder = pattern.substring(hashIndex + 1);
1211
+ // Check for precision specifier (.N)
1212
+ let precision;
1213
+ const precisionMatch = remainder.match(/^\.(\d+)/);
1214
+ if (precisionMatch) {
1215
+ precision = parseInt(precisionMatch[1], 10);
1216
+ remainder = remainder.substring(precisionMatch[0].length);
1217
+ }
1218
+ return { prefix, suffix: remainder, precision };
1219
+ }
1220
+ /**
1221
+ * Format a cell value according to its aggregate specification.
1222
+ */
1223
+ function formatValue(value, agg) {
1224
+ if (value === null || value === undefined)
1225
+ return '';
1226
+ if (agg?.format) {
1227
+ switch (agg.format.type) {
1228
+ case 'percent':
1229
+ // Standard percent format: 0.5 → 50%
1230
+ return `${(value * 100).toFixed(1)}%`;
1231
+ case 'rawPercent':
1232
+ // For percentage aggregates (ACROSS), Malloy already computes 100.0 * value / denominator
1233
+ // so the value is already in percentage form (59.44 = 59.44%), don't multiply again
1234
+ return `${value.toFixed(1)}%`;
1235
+ case 'integer':
1236
+ return Math.round(value).toLocaleString();
1237
+ case 'comma':
1238
+ return value.toLocaleString(undefined, {
1239
+ minimumFractionDigits: agg.format.precision,
1240
+ maximumFractionDigits: agg.format.precision,
1241
+ });
1242
+ case 'decimal':
1243
+ return value.toLocaleString(undefined, {
1244
+ minimumFractionDigits: agg.format.precision,
1245
+ maximumFractionDigits: agg.format.precision,
1246
+ });
1247
+ case 'currency':
1248
+ return `$${value.toLocaleString()}`;
1249
+ case 'custom': {
1250
+ // Parse custom format pattern: 'prefix #.precision suffix'
1251
+ const pattern = agg.format.pattern ?? '';
1252
+ const { prefix, suffix, precision } = parseCustomFormatPattern(pattern);
1253
+ const options = {};
1254
+ if (precision !== undefined) {
1255
+ options.minimumFractionDigits = precision;
1256
+ options.maximumFractionDigits = precision;
1257
+ }
1258
+ const formatted = value.toLocaleString(undefined, options);
1259
+ return `${prefix}${formatted}${suffix}`;
1260
+ }
1261
+ default:
1262
+ return String(value);
1263
+ }
1264
+ }
1265
+ // Default: locale-aware number formatting
1266
+ if (Number.isInteger(value)) {
1267
+ return value.toLocaleString();
1268
+ }
1269
+ return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
1270
+ }
1271
+ // ---
1272
+ // UTILITIES
1273
+ // ---
1274
+ /**
1275
+ * Verify that a plan query matches the structure of the data.
1276
+ */
1277
+ function verifyQueryMatch(query, data) {
1278
+ if (!data || data.length === 0)
1279
+ return false;
1280
+ const sample = data[0];
1281
+ const dataKeys = new Set(Object.keys(sample));
1282
+ // Check row groupings - data must have all row dimension keys
1283
+ for (const g of query.rowGroupings) {
1284
+ if (!dataKeys.has(g.dimension)) {
1285
+ return false;
1286
+ }
1287
+ }
1288
+ // If query expects row dimensions but data has none (total query mismatch)
1289
+ if (query.rowGroupings.length > 0) {
1290
+ const hasRowDim = query.rowGroupings.some(g => dataKeys.has(g.dimension));
1291
+ if (!hasRowDim)
1292
+ return false;
1293
+ }
1294
+ // If query expects no row dimensions (isRowTotal) but data has row dims
1295
+ if (query.isRowTotal && query.rowGroupings.length === 0) {
1296
+ // Data should NOT have row dimension keys (only nested column keys)
1297
+ const hasAnyRowDim = Array.from(dataKeys).some(k => !k.startsWith('by_') && k !== 'births_sum' && k !== 'births_mean');
1298
+ // Allow if it's just nested keys
1299
+ const onlyNestedKeys = Array.from(dataKeys).every(k => k.startsWith('by_') || k === 'births_sum' || k === 'births_mean');
1300
+ if (!onlyNestedKeys)
1301
+ return false;
1302
+ }
1303
+ // Check col groupings - verify full column path
1304
+ if (query.colGroupings.length > 0) {
1305
+ if (!hasFullColPath(sample, query.colGroupings, query.rowGroupings)) {
1306
+ return false;
1307
+ }
1308
+ }
1309
+ return true;
1310
+ }
1311
+ /**
1312
+ * Find a query in the plan that matches the structure of the given data.
1313
+ * Checks if the data has the expected row/column groupings.
1314
+ */
1315
+ function findMatchingQuery(plan, data) {
1316
+ if (!data || data.length === 0)
1317
+ return undefined;
1318
+ const sample = data[0];
1319
+ const dataKeys = new Set(Object.keys(sample));
1320
+ // Find a query whose groupings match the data structure
1321
+ for (const query of plan.queries) {
1322
+ let matches = true;
1323
+ // Check row groupings
1324
+ for (const g of query.rowGroupings) {
1325
+ if (!dataKeys.has(g.dimension)) {
1326
+ matches = false;
1327
+ break;
1328
+ }
1329
+ }
1330
+ // Check col groupings (look for by_X nested keys - check FULL column path)
1331
+ if (matches && query.colGroupings.length > 0) {
1332
+ // Navigate to leaf of row structure and verify full column path
1333
+ if (!hasFullColPath(sample, query.colGroupings, query.rowGroupings)) {
1334
+ matches = false;
1335
+ }
1336
+ }
1337
+ if (matches) {
1338
+ return query;
1339
+ }
1340
+ }
1341
+ return undefined;
1342
+ }
1343
+ /**
1344
+ * Check if a sample row has the full column path (all column dimensions).
1345
+ */
1346
+ function hasFullColPath(row, colGroupings, rowGroupings) {
1347
+ // Navigate through row nesting to find leaf
1348
+ let leafRow = row;
1349
+ for (let i = 1; i < rowGroupings.length; i++) {
1350
+ const rowNestedKey = `by_${rowGroupings[i].dimension}`;
1351
+ if (leafRow[rowNestedKey] && Array.isArray(leafRow[rowNestedKey]) && leafRow[rowNestedKey].length > 0) {
1352
+ leafRow = leafRow[rowNestedKey][0];
1353
+ }
1354
+ }
1355
+ // Now check full column path from leaf
1356
+ let current = leafRow;
1357
+ for (const colG of colGroupings) {
1358
+ const nestedKey = `by_${colG.dimension}`;
1359
+ if (!current[nestedKey] || !Array.isArray(current[nestedKey]) || current[nestedKey].length === 0) {
1360
+ return false;
1361
+ }
1362
+ current = current[nestedKey][0];
1363
+ }
1364
+ return true;
1365
+ }
1366
+ /**
1367
+ * Check if a sample row has a nested key (possibly through row nesting).
1368
+ */
1369
+ function hasNestedKey(row, nestedKey, rowGroupings) {
1370
+ // Check direct key
1371
+ if (row[nestedKey] && Array.isArray(row[nestedKey])) {
1372
+ return true;
1373
+ }
1374
+ // Navigate through row nesting
1375
+ for (let i = 1; i < rowGroupings.length; i++) {
1376
+ const rowNestedKey = `by_${rowGroupings[i].dimension}`;
1377
+ if (row[rowNestedKey] && Array.isArray(row[rowNestedKey]) && row[rowNestedKey].length > 0) {
1378
+ if (hasNestedKey(row[rowNestedKey][0], nestedKey, rowGroupings.slice(i))) {
1379
+ return true;
1380
+ }
1381
+ }
1382
+ }
1383
+ return false;
1384
+ }
1385
+ /**
1386
+ * Index query results when no matching plan query was found.
1387
+ * Auto-detects structure from the data itself.
1388
+ */
1389
+ function indexQueryResultsAuto(data, cellIndex, aggregates) {
1390
+ if (!data || data.length === 0)
1391
+ return;
1392
+ // Auto-detect groupings from data structure
1393
+ const sample = data[0];
1394
+ const keys = Object.keys(sample);
1395
+ // Find dimension keys (non-aggregate, non-nested)
1396
+ const aggregateNames = new Set(aggregates.map(a => a.name));
1397
+ const dimKeys = keys.filter(k => !k.startsWith('by_') &&
1398
+ !aggregateNames.has(k) &&
1399
+ typeof sample[k] !== 'object');
1400
+ // Find nested keys (by_X patterns)
1401
+ const nestedKeys = keys.filter(k => k.startsWith('by_'));
1402
+ // Build auto-detected groupings
1403
+ const rowGroupings = dimKeys.map(k => ({
1404
+ dimension: k,
1405
+ label: undefined,
1406
+ sort: undefined,
1407
+ limit: undefined,
1408
+ }));
1409
+ // If there are nested keys, assume they're column groupings
1410
+ const colGroupings = nestedKeys.map(k => ({
1411
+ dimension: k.replace('by_', ''),
1412
+ label: undefined,
1413
+ sort: undefined,
1414
+ limit: undefined,
1415
+ }));
1416
+ // Now index using detected structure
1417
+ flattenAndIndexAuto(data, rowGroupings, colGroupings, aggregates, new Map(), new Map(), cellIndex);
1418
+ }
1419
+ /**
1420
+ * Recursively flatten and index with auto-detected structure.
1421
+ */
1422
+ function flattenAndIndexAuto(data, rowGroupings, colGroupings, aggregates, baseRowValues, baseColValues, cellIndex) {
1423
+ for (const row of data) {
1424
+ const currentRowValues = new Map(baseRowValues);
1425
+ // Extract all dimension values from the row
1426
+ for (const g of rowGroupings) {
1427
+ const value = row[g.dimension];
1428
+ if (value !== undefined && value !== null) {
1429
+ currentRowValues.set(g.dimension, value);
1430
+ }
1431
+ }
1432
+ // Check for nested row data
1433
+ const nestedRowKeys = Object.keys(row).filter(k => k.startsWith('by_') &&
1434
+ !colGroupings.some(c => `by_${c.dimension}` === k));
1435
+ if (nestedRowKeys.length > 0) {
1436
+ // Has nested row data
1437
+ for (const nestedKey of nestedRowKeys) {
1438
+ const nestedData = row[nestedKey];
1439
+ if (Array.isArray(nestedData)) {
1440
+ flattenAndIndexAuto(nestedData, rowGroupings, colGroupings, aggregates, currentRowValues, baseColValues, cellIndex);
1441
+ }
1442
+ }
1443
+ }
1444
+ // Handle column pivots
1445
+ if (colGroupings.length > 0) {
1446
+ indexColumnPivotsAuto(row, colGroupings, aggregates, currentRowValues, new Map(), cellIndex, 0);
1447
+ }
1448
+ else {
1449
+ // Direct aggregate values
1450
+ indexAggregateValues(row, aggregates, currentRowValues, baseColValues, cellIndex);
1451
+ }
1452
+ }
1453
+ }
1454
+ /**
1455
+ * Index column pivots with auto-detected structure.
1456
+ */
1457
+ function indexColumnPivotsAuto(row, colGroupings, aggregates, rowValues, baseColValues, cellIndex, colDepth) {
1458
+ if (colDepth >= colGroupings.length) {
1459
+ indexAggregateValues(row, aggregates, rowValues, baseColValues, cellIndex);
1460
+ return;
1461
+ }
1462
+ const currentDim = colGroupings[colDepth];
1463
+ const nestedKey = `by_${currentDim.dimension}`;
1464
+ const nested = row[nestedKey];
1465
+ if (!nested || !Array.isArray(nested)) {
1466
+ // No more nesting - index here
1467
+ indexAggregateValues(row, aggregates, rowValues, baseColValues, cellIndex);
1468
+ return;
1469
+ }
1470
+ for (const colRow of nested) {
1471
+ const colValue = colRow[currentDim.dimension];
1472
+ const currentColValues = new Map(baseColValues);
1473
+ if (colValue !== undefined && colValue !== null) {
1474
+ currentColValues.set(currentDim.dimension, colValue);
1475
+ }
1476
+ indexColumnPivotsAuto(colRow, colGroupings, aggregates, rowValues, currentColValues, cellIndex, colDepth + 1);
1477
+ }
1478
+ }
1479
+ /**
1480
+ * Check if an axis tree contains a total node.
1481
+ */
1482
+ function axisHasTotal(node) {
1483
+ if (!node)
1484
+ return false;
1485
+ switch (node.nodeType) {
1486
+ case 'total':
1487
+ return true;
1488
+ case 'dimension':
1489
+ return node.child ? axisHasTotal(node.child) : false;
1490
+ case 'siblings':
1491
+ return node.children.some(c => axisHasTotal(c));
1492
+ case 'aggregate':
1493
+ case 'percentageAggregate':
1494
+ return false;
1495
+ }
1496
+ }
1497
+ // ---
1498
+ // DEBUGGING
1499
+ // ---
1500
+ /**
1501
+ * Print a GridSpec for debugging.
1502
+ */
1503
+ export function printGridSpec(grid) {
1504
+ const lines = [];
1505
+ lines.push('GridSpec:');
1506
+ lines.push('\n Row Headers:');
1507
+ printHeaderNodes(grid.rowHeaders, ' ', lines);
1508
+ lines.push('\n Column Headers:');
1509
+ printHeaderNodes(grid.colHeaders, ' ', lines);
1510
+ lines.push(`\n Aggregates: ${grid.aggregates.map(a => a.name).join(', ')}`);
1511
+ lines.push(` Has Row Total: ${grid.hasRowTotal}`);
1512
+ lines.push(` Has Col Total: ${grid.hasColTotal}`);
1513
+ return lines.join('\n');
1514
+ }
1515
+ function printHeaderNodes(nodes, indent, lines) {
1516
+ for (const node of nodes) {
1517
+ let line = `${indent}${node.type}: "${node.value}"`;
1518
+ if (node.dimension)
1519
+ line += ` (dim: ${node.dimension})`;
1520
+ line += ` span=${node.span} depth=${node.depth}`;
1521
+ lines.push(line);
1522
+ if (node.children) {
1523
+ printHeaderNodes(node.children, indent + ' ', lines);
1524
+ }
1525
+ }
1526
+ }
1527
+ // ---
1528
+ // CORNER ROW HEADERS
1529
+ // ---
1530
+ /**
1531
+ * Determine if corner-style row headers should be used.
1532
+ *
1533
+ * Corner headers (labels above in thead) are used when:
1534
+ * 1. Row axis has NO siblings ANYWHERE in the tree
1535
+ * (siblings require left-style because each branch may have different dimensions)
1536
+ * 2. User hasn't explicitly set rowHeaders:left
1537
+ *
1538
+ * The default behavior is:
1539
+ * - rowHeaders:above (corner style) when no siblings exist
1540
+ * - rowHeaders:left when siblings exist (forced fallback)
1541
+ * - Explicit option overrides the default if compatible
1542
+ *
1543
+ * When siblings exist anywhere (e.g., state * (gender | region)), we can't
1544
+ * use corner headers because the sibling branches would need different labels
1545
+ * in the same corner cell, which doesn't make sense.
1546
+ */
1547
+ function shouldUseCornerRowHeaders(spec) {
1548
+ // Check if row axis exists
1549
+ if (!spec.rowAxis) {
1550
+ return false;
1551
+ }
1552
+ // Check for siblings anywhere in the tree - if found, must use left-style
1553
+ if (hasSiblingsAnywhere(spec.rowAxis)) {
1554
+ return false;
1555
+ }
1556
+ // If user explicitly set rowHeaders:left, respect that
1557
+ if (spec.options.rowHeaders === 'left') {
1558
+ return false;
1559
+ }
1560
+ // Default to corner-style (above) when no siblings exist
1561
+ // This includes when rowHeaders is undefined or 'above'
1562
+ return true;
1563
+ }
1564
+ /**
1565
+ * Check if an axis tree contains "true" siblings anywhere (not just at root).
1566
+ *
1567
+ * "True siblings" means 2+ dimension children in a sibling group.
1568
+ * A single dimension with totals (dim | ALL) is NOT considered true siblings
1569
+ * because the structure is still linear - we can use corner-style headers.
1570
+ *
1571
+ * Note: The buildSiblingHeaders function separately decides whether to create
1572
+ * sibling-label wrappers (only for 2+ dimension siblings).
1573
+ */
1574
+ function hasSiblingsAnywhere(node) {
1575
+ if (!node)
1576
+ return false;
1577
+ if (node.nodeType === 'siblings') {
1578
+ // Count dimension children - only consider "true siblings" if 2+ dimensions
1579
+ const dimensionChildCount = node.children.filter(c => c.nodeType === 'dimension').length;
1580
+ if (dimensionChildCount >= 2) {
1581
+ return true; // Multiple dimensions → true siblings
1582
+ }
1583
+ // Check for nested sibling groups that each contain different dimensions
1584
+ // e.g., ((occupation | ALL) | (education | ALL)) has 2 sibling children,
1585
+ // each containing a different root dimension
1586
+ const siblingChildrenWithDims = node.children.filter(c => {
1587
+ if (c.nodeType === 'siblings') {
1588
+ // This sibling child contains dimensions (possibly with totals)
1589
+ return c.children.some(gc => gc.nodeType === 'dimension');
1590
+ }
1591
+ return false;
1592
+ });
1593
+ if (siblingChildrenWithDims.length >= 2) {
1594
+ // Multiple sibling groups each with dimensions → true siblings
1595
+ return true;
1596
+ }
1597
+ // Single dim + totals: check if the dimension has nested true siblings
1598
+ for (const child of node.children) {
1599
+ if (hasSiblingsAnywhere(child)) {
1600
+ return true;
1601
+ }
1602
+ }
1603
+ return false;
1604
+ }
1605
+ if (node.nodeType === 'dimension') {
1606
+ return hasSiblingsAnywhere(node.child ?? null);
1607
+ }
1608
+ if (node.nodeType === 'total') {
1609
+ return hasSiblingsAnywhere(node.child ?? null);
1610
+ }
1611
+ return false;
1612
+ }
1613
+ /**
1614
+ * Extract row dimension labels in nesting order for corner display.
1615
+ *
1616
+ * Walks the row axis tree and extracts dimension names and their labels.
1617
+ * For single-dimension sibling groups (dim | ALL), walks into the dimension
1618
+ * since the structure is still linear for header purposes.
1619
+ * Also handles aggregate siblings at the end of the chain.
1620
+ */
1621
+ function extractRowDimensionLabels(node) {
1622
+ const labels = [];
1623
+ let current = node;
1624
+ while (current) {
1625
+ if (current.nodeType === 'dimension') {
1626
+ const dimNode = current;
1627
+ labels.push({
1628
+ dimension: dimNode.name,
1629
+ label: dimNode.label ?? dimNode.name,
1630
+ });
1631
+ current = dimNode.child ?? null;
1632
+ }
1633
+ else if (current.nodeType === 'siblings') {
1634
+ // Check what kind of sibling group this is
1635
+ const siblingNode = current;
1636
+ const dimensionChildren = siblingNode.children.filter(c => c.nodeType === 'dimension');
1637
+ const aggregateChildren = siblingNode.children.filter(c => c.nodeType === 'aggregate' || c.nodeType === 'percentageAggregate');
1638
+ if (dimensionChildren.length === 1) {
1639
+ // Single dimension + totals: walk into the dimension
1640
+ // This handles patterns like (gender | ALL) where we still want the label
1641
+ current = dimensionChildren[0];
1642
+ }
1643
+ else if (dimensionChildren.length === 0 && aggregateChildren.length > 0) {
1644
+ // Aggregate siblings (e.g., income.(sum | mean))
1645
+ // Add a synthetic label for the aggregate column
1646
+ // The actual aggregate names will be shown in row headers
1647
+ labels.push({
1648
+ dimension: '_aggregate',
1649
+ label: '', // Aggregates show their own labels in row headers
1650
+ });
1651
+ break; // Aggregates are always leaves
1652
+ }
1653
+ else {
1654
+ // Multiple dimensions - stop collecting (true siblings need left-mode)
1655
+ break;
1656
+ }
1657
+ }
1658
+ else if (current.nodeType === 'total') {
1659
+ // Total node - check if it has a child to continue
1660
+ const totalNode = current;
1661
+ current = totalNode.child ?? null;
1662
+ }
1663
+ else if (current.nodeType === 'aggregate' || current.nodeType === 'percentageAggregate') {
1664
+ // Single aggregate - no header column needed
1665
+ break;
1666
+ }
1667
+ else {
1668
+ break;
1669
+ }
1670
+ }
1671
+ return labels;
1672
+ }
1673
+ /**
1674
+ * Extract row dimension labels for left-mode display (when siblings exist).
1675
+ *
1676
+ * In left mode, we show labels in the corner only when:
1677
+ * - The dimension has a custom label (user explicitly provided one)
1678
+ * - The dimension hasn't already been labeled at a different depth
1679
+ * - There are NO sibling-labels in the row headers (sibling-labels show labels in body)
1680
+ *
1681
+ * When the same dimension appears at different depths in different branches
1682
+ * (e.g., gender at depth 1 under ALL, but depth 2 under occupation), we show
1683
+ * the label at the DEEPEST depth where the dimension appears. This ensures
1684
+ * the label appears in the column that has the most values for that dimension.
1685
+ *
1686
+ * This walks all branches and finds dimensions with their labels,
1687
+ * returning one entry per header column depth.
1688
+ */
1689
+ function extractLeftModeRowLabels(rowHeaders) {
1690
+ // Get max depth of row headers
1691
+ function getMaxDepth(nodes) {
1692
+ let max = 0;
1693
+ for (const node of nodes) {
1694
+ max = Math.max(max, node.depth);
1695
+ if (node.children) {
1696
+ max = Math.max(max, getMaxDepth(node.children));
1697
+ }
1698
+ }
1699
+ return max;
1700
+ }
1701
+ // Check if row headers contain sibling-labels
1702
+ // If so, those labels are already displayed in body row headers, don't duplicate in corner
1703
+ function hasSiblingLabels(nodes) {
1704
+ for (const node of nodes) {
1705
+ if (node.type === 'sibling-label')
1706
+ return true;
1707
+ if (node.children && hasSiblingLabels(node.children))
1708
+ return true;
1709
+ }
1710
+ return false;
1711
+ }
1712
+ const maxDepth = getMaxDepth(rowHeaders);
1713
+ // If sibling-labels exist, they handle showing labels in body - corner should be empty
1714
+ if (hasSiblingLabels(rowHeaders)) {
1715
+ const result = [];
1716
+ for (let depth = 0; depth <= maxDepth; depth++) {
1717
+ result.push({ label: '', hasCustomLabel: false });
1718
+ }
1719
+ return result;
1720
+ }
1721
+ // First pass: find the deepest depth where each dimension with a custom label appears
1722
+ const dimensionToDeepestDepth = new Map();
1723
+ for (let depth = 0; depth <= maxDepth; depth++) {
1724
+ const labelsAtDepth = collectLabelsAtDepth(rowHeaders, depth);
1725
+ for (const info of labelsAtDepth) {
1726
+ if (info.hasCustomLabel && info.dimension) {
1727
+ // Always update to track the deepest occurrence
1728
+ const existing = dimensionToDeepestDepth.get(info.dimension);
1729
+ if (!existing || depth > existing.depth) {
1730
+ dimensionToDeepestDepth.set(info.dimension, { depth, label: info.label });
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ // Build result array
1736
+ const result = [];
1737
+ for (let depth = 0; depth <= maxDepth; depth++) {
1738
+ // Check if any dimension should have its label at this depth
1739
+ let hasCustom = false;
1740
+ let customLabel = '';
1741
+ let dimension;
1742
+ for (const [dim, info] of dimensionToDeepestDepth) {
1743
+ if (info.depth === depth) {
1744
+ hasCustom = true;
1745
+ customLabel = info.label;
1746
+ dimension = dim;
1747
+ break;
1748
+ }
1749
+ }
1750
+ result.push({
1751
+ dimension,
1752
+ label: customLabel,
1753
+ hasCustomLabel: hasCustom,
1754
+ });
1755
+ }
1756
+ return result;
1757
+ }
1758
+ /**
1759
+ * Collect label information from all header nodes at a specific depth.
1760
+ *
1761
+ * Note: sibling-label nodes are NOT marked as having custom labels for corner display
1762
+ * because they already display their labels in body row headers. This prevents
1763
+ * duplicate labels appearing in both corner and body.
1764
+ */
1765
+ function collectLabelsAtDepth(nodes, targetDepth) {
1766
+ const labels = [];
1767
+ function collect(node) {
1768
+ if (node.depth === targetDepth) {
1769
+ // Check if this node has a custom label
1770
+ // For dimension types: label is custom if node.label exists and differs from value
1771
+ // For sibling-label types: DON'T mark as custom - they show labels in body, not corner
1772
+ let hasCustomLabel = false;
1773
+ if (node.type === 'dimension') {
1774
+ // A dimension node has a custom label if node.label is set and non-empty
1775
+ // The node.label comes from the AST when user writes: gender "mf"
1776
+ hasCustomLabel = node.label !== undefined && node.label !== '' && node.label !== node.dimension;
1777
+ }
1778
+ else if (node.type === 'sibling-label') {
1779
+ // Sibling-labels display their labels in body row headers, not in corner cells.
1780
+ // Don't mark as custom to prevent duplicate label display.
1781
+ hasCustomLabel = false;
1782
+ }
1783
+ labels.push({
1784
+ dimension: node.dimension,
1785
+ label: node.label ?? node.value,
1786
+ hasCustomLabel,
1787
+ });
1788
+ }
1789
+ if (node.children) {
1790
+ for (const child of node.children) {
1791
+ collect(child);
1792
+ }
1793
+ }
1794
+ }
1795
+ for (const node of nodes) {
1796
+ collect(node);
1797
+ }
1798
+ return labels;
1799
+ }
1800
+ /**
1801
+ * Collect all dimension names from an axis tree.
1802
+ * Returns an empty array if the axis contains only aggregates (no dimensions).
1803
+ */
1804
+ function collectDimensionsFromAxis(node) {
1805
+ const dimensions = [];
1806
+ const seen = new Set();
1807
+ function walk(n) {
1808
+ if (!n)
1809
+ return;
1810
+ switch (n.nodeType) {
1811
+ case 'dimension':
1812
+ if (!seen.has(n.name)) {
1813
+ seen.add(n.name);
1814
+ dimensions.push(n.name);
1815
+ }
1816
+ if (n.child)
1817
+ walk(n.child);
1818
+ break;
1819
+ case 'total':
1820
+ if (n.child)
1821
+ walk(n.child);
1822
+ break;
1823
+ case 'siblings':
1824
+ for (const child of n.children) {
1825
+ walk(child);
1826
+ }
1827
+ break;
1828
+ case 'aggregate':
1829
+ case 'percentageAggregate':
1830
+ // Leaf nodes, no dimensions to collect
1831
+ break;
1832
+ }
1833
+ }
1834
+ walk(node);
1835
+ return dimensions;
1836
+ }