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.
- package/README.md +357 -0
- package/dist/compiler/grid-spec-builder.d.ts +30 -0
- package/dist/compiler/grid-spec-builder.js +1836 -0
- package/dist/compiler/index.d.ts +11 -0
- package/dist/compiler/index.js +13 -0
- package/dist/compiler/malloy-generator.d.ts +36 -0
- package/dist/compiler/malloy-generator.js +141 -0
- package/dist/compiler/multi-query-utils.d.ts +42 -0
- package/dist/compiler/multi-query-utils.js +185 -0
- package/dist/compiler/query-plan-generator.d.ts +77 -0
- package/dist/compiler/query-plan-generator.js +1456 -0
- package/dist/compiler/table-spec-builder.d.ts +11 -0
- package/dist/compiler/table-spec-builder.js +588 -0
- package/dist/compiler/table-spec.d.ts +434 -0
- package/dist/compiler/table-spec.js +274 -0
- package/dist/executor/index.d.ts +71 -0
- package/dist/executor/index.js +232 -0
- package/dist/index.d.ts +214 -0
- package/dist/index.js +220 -0
- package/dist/parser/ast.d.ts +253 -0
- package/dist/parser/ast.js +164 -0
- package/dist/parser/chevrotain-parser.d.ts +118 -0
- package/dist/parser/chevrotain-parser.js +1266 -0
- package/dist/parser/index.d.ts +30 -0
- package/dist/parser/index.js +36 -0
- package/dist/parser/parser.d.ts +4 -0
- package/dist/parser/parser.js +4354 -0
- package/dist/parser/prettifier.d.ts +14 -0
- package/dist/parser/prettifier.js +380 -0
- package/dist/renderer/grid-renderer.d.ts +19 -0
- package/dist/renderer/grid-renderer.js +541 -0
- package/dist/renderer/index.d.ts +4 -0
- package/dist/renderer/index.js +4 -0
- package/package.json +67 -0
- package/packages/parser/tpl.pegjs +568 -0
- package/packages/renderer/tpl-table.css +182 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid Renderer
|
|
3
|
+
*
|
|
4
|
+
* Renders a GridSpec directly to HTML.
|
|
5
|
+
*
|
|
6
|
+
* Key simplification: GridSpec already has the complete header hierarchy
|
|
7
|
+
* with pre-computed spans, so we don't need to reconstruct structure.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Render a GridSpec to HTML.
|
|
11
|
+
*/
|
|
12
|
+
export function renderGridToHTML(grid, options = {}) {
|
|
13
|
+
const { tableClass = 'tpl-table', showDimensionLabels = true, } = options;
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(`<table class="${tableClass}">`);
|
|
16
|
+
// Render column headers
|
|
17
|
+
renderColumnHeaders(grid, lines, showDimensionLabels);
|
|
18
|
+
// Render body (row headers + data cells)
|
|
19
|
+
renderBody(grid, lines, showDimensionLabels);
|
|
20
|
+
lines.push('</table>');
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
// ---
|
|
24
|
+
// COLUMN HEADERS
|
|
25
|
+
// ---
|
|
26
|
+
/**
|
|
27
|
+
* Get the maximum depth of header nodes.
|
|
28
|
+
*/
|
|
29
|
+
function getMaxDepth(nodes) {
|
|
30
|
+
let maxDepth = 0;
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
maxDepth = Math.max(maxDepth, node.depth);
|
|
33
|
+
if (node.children) {
|
|
34
|
+
maxDepth = Math.max(maxDepth, getMaxDepth(node.children));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return maxDepth;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Collect all nodes at a specific depth level.
|
|
41
|
+
*/
|
|
42
|
+
function getNodesAtDepth(nodes, targetDepth) {
|
|
43
|
+
const result = [];
|
|
44
|
+
function collect(node) {
|
|
45
|
+
if (node.depth === targetDepth) {
|
|
46
|
+
result.push(node);
|
|
47
|
+
}
|
|
48
|
+
if (node.children) {
|
|
49
|
+
for (const child of node.children) {
|
|
50
|
+
collect(child);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
collect(node);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if aggregates are on the row axis (i.e., row headers contain _aggregate dimension).
|
|
61
|
+
*/
|
|
62
|
+
function areAggregatesOnRowAxis(rowHeaders) {
|
|
63
|
+
function checkNode(node) {
|
|
64
|
+
if (node.dimension === '_aggregate') {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (node.children) {
|
|
68
|
+
for (const child of node.children) {
|
|
69
|
+
if (checkNode(child))
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
for (const header of rowHeaders) {
|
|
76
|
+
if (checkNode(header))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Render column header rows.
|
|
83
|
+
*/
|
|
84
|
+
function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
85
|
+
// Get row header column count - if corner headers, we still have columns but labels go in corner
|
|
86
|
+
const rowHeaderCols = grid.useCornerRowHeaders
|
|
87
|
+
? (grid.cornerRowLabels?.length ?? 0)
|
|
88
|
+
: countRowHeaderColumns(grid.rowHeaders);
|
|
89
|
+
// Check if aggregates are rendered as row headers
|
|
90
|
+
const aggregatesOnRowAxis = areAggregatesOnRowAxis(grid.rowHeaders);
|
|
91
|
+
if (grid.colHeaders.length === 0 && grid.aggregates.length === 1) {
|
|
92
|
+
// No column headers - single aggregate with row-only layout
|
|
93
|
+
// Still need a header row for the aggregate
|
|
94
|
+
lines.push('<thead>');
|
|
95
|
+
lines.push('<tr>');
|
|
96
|
+
// Corner cells for row header columns - with labels if corner style or left mode with custom labels
|
|
97
|
+
if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
|
|
98
|
+
for (const labelInfo of grid.cornerRowLabels) {
|
|
99
|
+
const hasLabel = labelInfo.label.trim() !== '';
|
|
100
|
+
const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
|
|
101
|
+
lines.push(`<th class="${classes}">${escapeHTML(labelInfo.label)}</th>`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (grid.leftModeRowLabels) {
|
|
105
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
106
|
+
const labelInfo = grid.leftModeRowLabels[i];
|
|
107
|
+
if (labelInfo?.hasCustomLabel) {
|
|
108
|
+
lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
117
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Single aggregate header
|
|
121
|
+
lines.push(`<th>${escapeHTML(grid.aggregates[0]?.label ?? grid.aggregates[0]?.name ?? 'Value')}</th>`);
|
|
122
|
+
lines.push('</tr>');
|
|
123
|
+
lines.push('</thead>');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (grid.colHeaders.length === 0) {
|
|
127
|
+
// Multiple aggregates but no column dimensions
|
|
128
|
+
lines.push('<thead>');
|
|
129
|
+
lines.push('<tr>');
|
|
130
|
+
// Corner cells with labels if corner style or left mode with custom labels
|
|
131
|
+
if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
|
|
132
|
+
for (const labelInfo of grid.cornerRowLabels) {
|
|
133
|
+
const hasLabel = labelInfo.label.trim() !== '';
|
|
134
|
+
const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
|
|
135
|
+
lines.push(`<th class="${classes}">${escapeHTML(labelInfo.label)}</th>`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (grid.leftModeRowLabels) {
|
|
139
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
140
|
+
const labelInfo = grid.leftModeRowLabels[i];
|
|
141
|
+
if (labelInfo?.hasCustomLabel) {
|
|
142
|
+
lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
151
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// If aggregates are on the row axis, render a single "Value" column header
|
|
155
|
+
// (each row will have one cell for its specific aggregate)
|
|
156
|
+
if (aggregatesOnRowAxis) {
|
|
157
|
+
lines.push('<th>Value</th>');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// Aggregates are not on row axis, render each aggregate as a column header
|
|
161
|
+
for (const agg of grid.aggregates) {
|
|
162
|
+
lines.push(`<th>${escapeHTML(agg.label ?? agg.name)}</th>`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
lines.push('</tr>');
|
|
166
|
+
lines.push('</thead>');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Multi-level column headers
|
|
170
|
+
const maxDepth = getMaxDepth(grid.colHeaders);
|
|
171
|
+
lines.push('<thead>');
|
|
172
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
173
|
+
lines.push('<tr>');
|
|
174
|
+
// Corner cells for row header columns - render per row instead of using rowspan
|
|
175
|
+
// Labels only appear in the last row (depth === maxDepth)
|
|
176
|
+
const isLastRow = depth === maxDepth;
|
|
177
|
+
if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
|
|
178
|
+
// Corner-style: render row dimension labels in corner on the LAST row of thead
|
|
179
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
180
|
+
if (isLastRow) {
|
|
181
|
+
const labelText = grid.cornerRowLabels[i]?.label ?? '';
|
|
182
|
+
const hasLabel = labelText.trim() !== '';
|
|
183
|
+
const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
|
|
184
|
+
lines.push(`<th class="${classes}">${escapeHTML(labelText)}</th>`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (grid.leftModeRowLabels) {
|
|
192
|
+
// Left mode with sibling structure: show only custom labels in last row
|
|
193
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
194
|
+
if (isLastRow) {
|
|
195
|
+
const labelInfo = grid.leftModeRowLabels[i];
|
|
196
|
+
if (labelInfo?.hasCustomLabel) {
|
|
197
|
+
lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Default: empty corner cells
|
|
210
|
+
for (let i = 0; i < rowHeaderCols; i++) {
|
|
211
|
+
lines.push('<th class="tpl-corner"></th>');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Column headers at this depth
|
|
215
|
+
const nodesAtDepth = getNodesAtDepth(grid.colHeaders, depth);
|
|
216
|
+
for (const node of nodesAtDepth) {
|
|
217
|
+
const rowspan = node.children ? 1 : (maxDepth - node.depth + 1);
|
|
218
|
+
const colspan = node.span > 1 ? ` colspan="${node.span}"` : '';
|
|
219
|
+
const rowspanAttr = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
|
|
220
|
+
const cssClasses = [];
|
|
221
|
+
if (node.type === 'total')
|
|
222
|
+
cssClasses.push('total-col');
|
|
223
|
+
if (node.type === 'sibling-label')
|
|
224
|
+
cssClasses.push('sibling-label');
|
|
225
|
+
const cssClass = cssClasses.length > 0 ? ` class="${cssClasses.join(' ')}"` : '';
|
|
226
|
+
lines.push(`<th${colspan}${rowspanAttr}${cssClass}>${escapeHTML(node.value)}</th>`);
|
|
227
|
+
}
|
|
228
|
+
lines.push('</tr>');
|
|
229
|
+
}
|
|
230
|
+
lines.push('</thead>');
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Count the number of columns needed for row headers.
|
|
234
|
+
*/
|
|
235
|
+
function countRowHeaderColumns(rowHeaders) {
|
|
236
|
+
if (rowHeaders.length === 0)
|
|
237
|
+
return 0;
|
|
238
|
+
// Count depth levels (each level gets a column)
|
|
239
|
+
const maxDepth = getMaxDepth(rowHeaders);
|
|
240
|
+
return maxDepth + 1;
|
|
241
|
+
}
|
|
242
|
+
// ---
|
|
243
|
+
// BODY (ROW HEADERS + DATA)
|
|
244
|
+
// ---
|
|
245
|
+
/**
|
|
246
|
+
* Collect all leaf header nodes (nodes without children).
|
|
247
|
+
*/
|
|
248
|
+
function collectLeafNodes(nodes) {
|
|
249
|
+
const leaves = [];
|
|
250
|
+
function collect(node) {
|
|
251
|
+
if (!node.children || node.children.length === 0) {
|
|
252
|
+
leaves.push(node);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
for (const child of node.children) {
|
|
256
|
+
collect(child);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const node of nodes) {
|
|
261
|
+
collect(node);
|
|
262
|
+
}
|
|
263
|
+
return leaves;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Collect dimension values from root to a leaf node.
|
|
267
|
+
*/
|
|
268
|
+
function collectDimensionValues(leaf, allNodes) {
|
|
269
|
+
const values = new Map();
|
|
270
|
+
// Build path from root to this leaf
|
|
271
|
+
// We need to find ancestors by matching spans and positions
|
|
272
|
+
// For simplicity, we'll just collect from the leaf and its siblings structure
|
|
273
|
+
// Add the leaf's dimension value
|
|
274
|
+
if (leaf.dimension && leaf.dimension !== '_aggregate') {
|
|
275
|
+
const numValue = Number(leaf.value);
|
|
276
|
+
values.set(leaf.dimension, isNaN(numValue) ? leaf.value : numValue);
|
|
277
|
+
}
|
|
278
|
+
// TODO: For nested headers, we need to track the path through the tree
|
|
279
|
+
// This is simplified - for now we only handle flat row dimensions
|
|
280
|
+
return values;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Render table body with row headers and data cells.
|
|
284
|
+
*/
|
|
285
|
+
function renderBody(grid, lines, showDimensionLabels) {
|
|
286
|
+
lines.push('<tbody>');
|
|
287
|
+
// Get all leaf row nodes (each becomes a data row)
|
|
288
|
+
const rowLeaves = collectLeafNodes(grid.rowHeaders);
|
|
289
|
+
const colLeaves = grid.colHeaders.length > 0 ? collectLeafNodes(grid.colHeaders) : [];
|
|
290
|
+
// Track which row headers have been rendered (for rowspan)
|
|
291
|
+
const maxRowDepth = getMaxDepth(grid.rowHeaders);
|
|
292
|
+
const renderedAt = new Map(); // "depth:value" -> rendered
|
|
293
|
+
for (let rowIdx = 0; rowIdx < rowLeaves.length; rowIdx++) {
|
|
294
|
+
lines.push('<tr>');
|
|
295
|
+
// Render row headers for this row
|
|
296
|
+
renderRowHeaders(grid, rowLeaves, rowIdx, maxRowDepth, renderedAt, lines);
|
|
297
|
+
// Render data cells
|
|
298
|
+
const rowValues = collectRowDimensionValues(grid.rowHeaders, rowLeaves[rowIdx]);
|
|
299
|
+
const rowLeaf = rowLeaves[rowIdx];
|
|
300
|
+
if (colLeaves.length > 0) {
|
|
301
|
+
// With column pivots
|
|
302
|
+
for (const colLeaf of colLeaves) {
|
|
303
|
+
const colValues = collectColDimensionValues(grid.colHeaders, colLeaf);
|
|
304
|
+
renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, grid.rowHeaders, grid.colHeaders, lines);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
// No column pivots - render aggregate values directly
|
|
309
|
+
renderDataCells(grid, rowValues, rowLeaf, lines);
|
|
310
|
+
}
|
|
311
|
+
lines.push('</tr>');
|
|
312
|
+
}
|
|
313
|
+
lines.push('</tbody>');
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Render row header cells for a data row.
|
|
317
|
+
*/
|
|
318
|
+
function renderRowHeaders(grid, rowLeaves, rowIdx, maxDepth, renderedAt, lines) {
|
|
319
|
+
// Find the path from root to this leaf
|
|
320
|
+
const leaf = rowLeaves[rowIdx];
|
|
321
|
+
const path = findPathToLeaf(grid.rowHeaders, leaf);
|
|
322
|
+
// When using corner row headers, skip sibling-label nodes (their labels are in corner)
|
|
323
|
+
// and adjust depths accordingly
|
|
324
|
+
const useCornerHeaders = grid.useCornerRowHeaders;
|
|
325
|
+
// Filter path to only include renderable nodes for corner style
|
|
326
|
+
const renderablePath = useCornerHeaders
|
|
327
|
+
? path.filter(n => n.type !== 'sibling-label')
|
|
328
|
+
: path;
|
|
329
|
+
// Calculate effective max depth for corner style
|
|
330
|
+
const effectiveMaxDepth = useCornerHeaders
|
|
331
|
+
? (grid.cornerRowLabels?.length ?? 1) - 1
|
|
332
|
+
: maxDepth;
|
|
333
|
+
for (let depth = 0; depth <= effectiveMaxDepth; depth++) {
|
|
334
|
+
// For corner headers, find the node that matches this column position
|
|
335
|
+
// (nodes are already filtered to exclude sibling-labels)
|
|
336
|
+
const nodeAtDepth = useCornerHeaders
|
|
337
|
+
? renderablePath[depth]
|
|
338
|
+
: path.find(n => n.depth === depth);
|
|
339
|
+
if (nodeAtDepth) {
|
|
340
|
+
const key = `${depth}:${nodeAtDepth.value}:${getPathKey(path, depth)}`;
|
|
341
|
+
if (!renderedAt.has(key)) {
|
|
342
|
+
// First time seeing this header - render with rowspan
|
|
343
|
+
renderedAt.set(key, true);
|
|
344
|
+
const rowspan = nodeAtDepth.span > 1 ? ` rowspan="${nodeAtDepth.span}"` : '';
|
|
345
|
+
const cssClasses = [];
|
|
346
|
+
if (nodeAtDepth.type === 'total')
|
|
347
|
+
cssClasses.push('total-row');
|
|
348
|
+
if (nodeAtDepth.type === 'sibling-label' && !useCornerHeaders)
|
|
349
|
+
cssClasses.push('sibling-label');
|
|
350
|
+
const cssClass = cssClasses.length > 0 ? ` class="${cssClasses.join(' ')}"` : '';
|
|
351
|
+
// If this is a leaf node that doesn't reach maxDepth, add colspan to fill remaining columns
|
|
352
|
+
const isLeaf = useCornerHeaders
|
|
353
|
+
? (depth === renderablePath.length - 1)
|
|
354
|
+
: (nodeAtDepth === leaf);
|
|
355
|
+
const remainingDepth = effectiveMaxDepth - depth;
|
|
356
|
+
const colspan = isLeaf && remainingDepth > 0 ? ` colspan="${remainingDepth + 1}"` : '';
|
|
357
|
+
lines.push(`<th${rowspan}${colspan}${cssClass}>${escapeHTML(nodeAtDepth.value)}</th>`);
|
|
358
|
+
// If we added colspan, skip the remaining depths in this iteration
|
|
359
|
+
if (isLeaf && remainingDepth > 0) {
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// else: already rendered, skip (covered by rowspan)
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// No node at this depth for this row - might happen with siblings
|
|
367
|
+
// Leave as empty (rowspan should cover from a parent)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Find the path from root to a leaf node.
|
|
373
|
+
*/
|
|
374
|
+
function findPathToLeaf(roots, target) {
|
|
375
|
+
function search(node, path) {
|
|
376
|
+
const currentPath = [...path, node];
|
|
377
|
+
if (node === target) {
|
|
378
|
+
return currentPath;
|
|
379
|
+
}
|
|
380
|
+
if (node.children) {
|
|
381
|
+
for (const child of node.children) {
|
|
382
|
+
const result = search(child, currentPath);
|
|
383
|
+
if (result)
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
for (const root of roots) {
|
|
390
|
+
const result = search(root, []);
|
|
391
|
+
if (result)
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
return [target]; // Fallback: just the target
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Get a unique key for a path up to a certain depth.
|
|
398
|
+
* Includes sibling indices from the node's path to distinguish headers
|
|
399
|
+
* in different sibling groups that have the same dimension/value.
|
|
400
|
+
*/
|
|
401
|
+
function getPathKey(path, upToDepth) {
|
|
402
|
+
const nodesUpToDepth = path.filter(n => n.depth <= upToDepth);
|
|
403
|
+
return nodesUpToDepth
|
|
404
|
+
.map(n => {
|
|
405
|
+
// Include sibling indices from the node's path for uniqueness
|
|
406
|
+
const siblingPrefix = n.path
|
|
407
|
+
?.filter(seg => seg.type === 'sibling')
|
|
408
|
+
.map(seg => seg.index)
|
|
409
|
+
.join(',') ?? '';
|
|
410
|
+
return `${siblingPrefix}:${n.dimension}:${n.value}`;
|
|
411
|
+
})
|
|
412
|
+
.join('|');
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Collect row dimension values from the path to a leaf.
|
|
416
|
+
*/
|
|
417
|
+
function collectRowDimensionValues(roots, leaf) {
|
|
418
|
+
const values = new Map();
|
|
419
|
+
const path = findPathToLeaf(roots, leaf);
|
|
420
|
+
for (const node of path) {
|
|
421
|
+
if (node.dimension && node.dimension !== '_aggregate') {
|
|
422
|
+
const numValue = Number(node.value);
|
|
423
|
+
values.set(node.dimension, isNaN(numValue) ? node.value : numValue);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return values;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Collect column dimension values from the path to a leaf.
|
|
430
|
+
*/
|
|
431
|
+
function collectColDimensionValues(roots, leaf) {
|
|
432
|
+
const values = new Map();
|
|
433
|
+
const path = findPathToLeaf(roots, leaf);
|
|
434
|
+
for (const node of path) {
|
|
435
|
+
if (node.dimension && node.dimension !== '_aggregate') {
|
|
436
|
+
const numValue = Number(node.value);
|
|
437
|
+
values.set(node.dimension, isNaN(numValue) ? node.value : numValue);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return values;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Find the aggregate name from a header node with _aggregate dimension.
|
|
444
|
+
*
|
|
445
|
+
* First checks the path for an aggregate segment (most reliable for sibling aggregates),
|
|
446
|
+
* then falls back to matching by label/name.
|
|
447
|
+
*/
|
|
448
|
+
function findAggregateFromHeader(header, aggregates) {
|
|
449
|
+
if (header.dimension !== '_aggregate')
|
|
450
|
+
return undefined;
|
|
451
|
+
// First, check the path for an aggregate segment - this is the most reliable method
|
|
452
|
+
// because it captures the exact aggregate from the tree structure
|
|
453
|
+
for (const segment of header.path) {
|
|
454
|
+
if (segment.type === 'aggregate') {
|
|
455
|
+
// Verify this aggregate exists in our list
|
|
456
|
+
const agg = aggregates.find(a => a.name === segment.name);
|
|
457
|
+
if (agg) {
|
|
458
|
+
return agg.name;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Fallback: Match by label or formatted name
|
|
463
|
+
const agg = aggregates.find(a => a.label === header.value ||
|
|
464
|
+
a.name === header.value ||
|
|
465
|
+
formatAggName(a.name) === header.value);
|
|
466
|
+
return agg?.name;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Format aggregate name for display matching (e.g., "births_sum" -> "births sum")
|
|
470
|
+
*/
|
|
471
|
+
function formatAggName(name) {
|
|
472
|
+
return name.replace(/_/g, ' ');
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Check if a header node is or is part of a total path.
|
|
476
|
+
*/
|
|
477
|
+
function isInTotalPath(leaf, roots) {
|
|
478
|
+
const path = findPathToLeaf(roots, leaf);
|
|
479
|
+
return path.some(node => node.type === 'total');
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Render a single data cell.
|
|
483
|
+
*/
|
|
484
|
+
function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots, colRoots, lines) {
|
|
485
|
+
// Determine which aggregate to use - check both row and column
|
|
486
|
+
let aggregateName;
|
|
487
|
+
// Check if row has aggregate (e.g., ROWS ... * (sum | mean))
|
|
488
|
+
aggregateName = findAggregateFromHeader(rowLeaf, grid.aggregates);
|
|
489
|
+
// Check if column has aggregate (e.g., COLS ... * (sum | mean))
|
|
490
|
+
if (!aggregateName) {
|
|
491
|
+
aggregateName = findAggregateFromHeader(colLeaf, grid.aggregates);
|
|
492
|
+
}
|
|
493
|
+
const cell = grid.getCell(rowValues, colValues, aggregateName);
|
|
494
|
+
// Build CSS classes for totals
|
|
495
|
+
const classes = [];
|
|
496
|
+
if (isInTotalPath(rowLeaf, rowRoots)) {
|
|
497
|
+
classes.push('total-row');
|
|
498
|
+
}
|
|
499
|
+
if (isInTotalPath(colLeaf, colRoots)) {
|
|
500
|
+
classes.push('total-col');
|
|
501
|
+
}
|
|
502
|
+
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
|
|
503
|
+
lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Render data cells when there are no column pivots.
|
|
507
|
+
*/
|
|
508
|
+
function renderDataCells(grid, rowValues, rowLeaf, lines) {
|
|
509
|
+
const colValues = new Map();
|
|
510
|
+
// Check if row specifies the aggregate
|
|
511
|
+
const rowAgg = findAggregateFromHeader(rowLeaf, grid.aggregates);
|
|
512
|
+
// Check if this row is a total row
|
|
513
|
+
const isTotalRow = isInTotalPath(rowLeaf, grid.rowHeaders);
|
|
514
|
+
const classAttr = isTotalRow ? ' class="total-row"' : '';
|
|
515
|
+
if (rowAgg) {
|
|
516
|
+
// Single aggregate cell based on row header
|
|
517
|
+
const cell = grid.getCell(rowValues, colValues, rowAgg);
|
|
518
|
+
lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
// Multiple aggregate cells
|
|
522
|
+
for (const agg of grid.aggregates) {
|
|
523
|
+
const cell = grid.getCell(rowValues, colValues, agg.name);
|
|
524
|
+
lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// ---
|
|
529
|
+
// UTILITIES
|
|
530
|
+
// ---
|
|
531
|
+
/**
|
|
532
|
+
* Escape HTML special characters.
|
|
533
|
+
*/
|
|
534
|
+
function escapeHTML(str) {
|
|
535
|
+
return str
|
|
536
|
+
.replace(/&/g, '&')
|
|
537
|
+
.replace(/</g, '<')
|
|
538
|
+
.replace(/>/g, '>')
|
|
539
|
+
.replace(/"/g, '"')
|
|
540
|
+
.replace(/'/g, ''');
|
|
541
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tplm-lang",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TPLm - Table Producing Language backed by Malloy.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/**/*.js",
|
|
16
|
+
"dist/**/*.d.ts",
|
|
17
|
+
"packages/parser/tpl.pegjs",
|
|
18
|
+
"packages/renderer/tpl-table.css",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build:parser": "peggy --format es packages/parser/tpl.pegjs -o packages/parser/parser.js && mkdir -p dist/parser && cp packages/parser/parser.js packages/parser/parser.d.ts dist/parser/",
|
|
24
|
+
"build": "npm run build:parser && tsc",
|
|
25
|
+
"test": "vitest",
|
|
26
|
+
"test:run": "vitest run",
|
|
27
|
+
"docs:dev": "cd docs-site && npm run docs:dev",
|
|
28
|
+
"docs:build": "cd docs-site && npm run docs:build",
|
|
29
|
+
"docs:preview": "cd docs-site && npm run docs:preview"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"peggy": "^4.0.0",
|
|
34
|
+
"typescript": "^5.0.0",
|
|
35
|
+
"vitest": "^4.0.16"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@malloydata/db-bigquery": "^0.0.326",
|
|
39
|
+
"@malloydata/db-duckdb": "^0.0.326",
|
|
40
|
+
"@malloydata/malloy": "^0.0.326",
|
|
41
|
+
"chevrotain": "^11.0.3"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"tplm",
|
|
48
|
+
"tpl",
|
|
49
|
+
"table",
|
|
50
|
+
"crosstab",
|
|
51
|
+
"pivot",
|
|
52
|
+
"analytics",
|
|
53
|
+
"malloy",
|
|
54
|
+
"duckdb",
|
|
55
|
+
"bigquery",
|
|
56
|
+
"data-visualization"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "https://github.com/jasonphillips/tplm"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/jasonphillips/tplm#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/jasonphillips/tplm/issues"
|
|
66
|
+
}
|
|
67
|
+
}
|