@peter.prib/renderengine 0.13.0 → 0.14.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 +454 -302
- package/dist/renderengine-workspace.umd.js +2203 -113
- package/dist/renderengine.umd.js +2203 -113
- package/documentation/REFERENCE.md +30 -1
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +544 -21
- package/src/index.js +98 -2
package/dist/renderengine.umd.js
CHANGED
|
@@ -1340,12 +1340,15 @@
|
|
|
1340
1340
|
},
|
|
1341
1341
|
{
|
|
1342
1342
|
key: 'stackedBar', label: 'Stacked Bar', method: 'stackedBarChart', category: '2d',
|
|
1343
|
-
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend:
|
|
1343
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
1344
|
+
seriesCapable: true,
|
|
1344
1345
|
axes: [
|
|
1345
1346
|
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
1346
|
-
{ role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required:
|
|
1347
|
+
{ role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
|
|
1348
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
|
|
1349
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
|
|
1347
1350
|
],
|
|
1348
|
-
dataShape: "Array<{label, values:number[]}>
|
|
1351
|
+
dataShape: "Array<{label, values:number[]}> or multi-series Array<{name, values:Array<{label,value}>}> — segments stacked from zero, with optional series legend",
|
|
1349
1352
|
streamShape: 'flat', // a new stacked bar is a whole new {label,values} record appended to the outer array, not growth of an existing record's values
|
|
1350
1353
|
},
|
|
1351
1354
|
{
|
|
@@ -1586,6 +1589,50 @@
|
|
|
1586
1589
|
dataShape: 'Array<{label, x, y, z, size?, category?}> — real orbit camera (drag-rotate/wheel-zoom); a future sphere/ellipsoid variant is this same shape with a second shape-size-channeled role, not a new chart type',
|
|
1587
1590
|
streamShape: 'flat',
|
|
1588
1591
|
},
|
|
1592
|
+
{
|
|
1593
|
+
key: 'pareto', label: 'Pareto', method: 'paretoChart', category: '2d',
|
|
1594
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SEMANTIC, camera: CAMERA_MODE.NONE,
|
|
1595
|
+
axes: [
|
|
1596
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
1597
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
|
|
1598
|
+
],
|
|
1599
|
+
dataShape: 'Array<{label, value}> — sorted descending with a right-hand 0-100% cumulative percentage line and 80% cutoff reference line',
|
|
1600
|
+
streamShape: 'flat',
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
key: 'lollipop', label: 'Lollipop', method: 'lollipopChart', category: '2d',
|
|
1604
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
1605
|
+
seriesCapable: true,
|
|
1606
|
+
axes: [
|
|
1607
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
1608
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
|
|
1609
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
|
|
1610
|
+
],
|
|
1611
|
+
dataShape: 'Array<{label, value}> or multi-series Array<{name, values:Array<{label,value}>}> — stem-and-dot alternative to bars',
|
|
1612
|
+
streamShape: 'flat',
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
key: 'marimekko', label: 'Marimekko', method: 'marimekkoChart', category: '2d',
|
|
1616
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
1617
|
+
seriesCapable: true,
|
|
1618
|
+
axes: [
|
|
1619
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.SHAPE_WIDTH, required: true },
|
|
1620
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_HEIGHT, required: true },
|
|
1621
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: true },
|
|
1622
|
+
],
|
|
1623
|
+
dataShape: 'Multi-series Array<{name, values:Array<{label,value}>}> or Array<{label, values:number[]}> — variable-width 100%-stacked mosaic columns',
|
|
1624
|
+
streamShape: 'series',
|
|
1625
|
+
},
|
|
1626
|
+
{
|
|
1627
|
+
key: 'icicle', label: 'Icicle', method: 'icicleChart', category: '2d',
|
|
1628
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
1629
|
+
axes: [
|
|
1630
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.ANGLE_OR_AREA, required: true },
|
|
1631
|
+
{ role: 'group', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
1632
|
+
],
|
|
1633
|
+
dataShape: 'Array<{label, value?, children?}> — rectangular hierarchical partition chart (top-down bands by depth, width proportional to value)',
|
|
1634
|
+
streamShape: 'flat',
|
|
1635
|
+
},
|
|
1589
1636
|
{
|
|
1590
1637
|
key: 'bar3DOrbit', label: 'Bar (3D Orbit)', method: 'barChart3DOrbit', category: '3d',
|
|
1591
1638
|
coordSystem: COORD_SYSTEM.ORBIT_3D, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ORBIT,
|
|
@@ -11218,8 +11265,10 @@
|
|
|
11218
11265
|
const box = ChartBox.fromRenderer(this.renderer);
|
|
11219
11266
|
const {
|
|
11220
11267
|
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
11221
|
-
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
11268
|
+
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
11269
|
+
normalize = false, stackMode, legend: showLegend = false,
|
|
11222
11270
|
} = options;
|
|
11271
|
+
const isPercent = Boolean(stacked && (normalize || stackMode === 'percent'));
|
|
11223
11272
|
const plotBox = { x, y, width, height };
|
|
11224
11273
|
const isMultiline = Array.isArray(data[0]?.values);
|
|
11225
11274
|
const series = isMultiline ? data : [{ name: 'default', values: data }];
|
|
@@ -11228,6 +11277,10 @@
|
|
|
11228
11277
|
const palette = new ColorPalette(colors);
|
|
11229
11278
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
11230
11279
|
|
|
11280
|
+
const pointTotals = isPercent
|
|
11281
|
+
? Array.from({ length: pointCount }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s.values[i]?.value ?? 0), 0))
|
|
11282
|
+
: null;
|
|
11283
|
+
|
|
11231
11284
|
// Precompute each series' [base, top] band per point — base is 0
|
|
11232
11285
|
// unless stacked, in which case it's the running cumulative of prior
|
|
11233
11286
|
// series (computed once here, not re-derived during render).
|
|
@@ -11235,12 +11288,15 @@
|
|
|
11235
11288
|
const bands = series.map(s => {
|
|
11236
11289
|
const band = s.values.map((d, i) => {
|
|
11237
11290
|
const base = stacked ? running[i] : 0;
|
|
11238
|
-
|
|
11291
|
+
const val = isPercent
|
|
11292
|
+
? (pointTotals[i] > 0 ? (Math.max(0, d.value) / pointTotals[i]) * 100 : 0)
|
|
11293
|
+
: d.value;
|
|
11294
|
+
return { base, top: base + val, pct: val };
|
|
11239
11295
|
});
|
|
11240
11296
|
if (stacked) running = band.map(b => b.top);
|
|
11241
11297
|
return band;
|
|
11242
11298
|
});
|
|
11243
|
-
const maxValue = Math.max(...bands.flat().map(b => b.top)) || 1;
|
|
11299
|
+
const maxValue = isPercent ? 100 : (Math.max(...bands.flat().map(b => b.top)) || 1);
|
|
11244
11300
|
|
|
11245
11301
|
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
11246
11302
|
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
@@ -11255,6 +11311,14 @@
|
|
|
11255
11311
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
11256
11312
|
this.renderer.path(areaPath, `rgba(${r},${g},${bl},0.35)`, null, { tooltip: s.name });
|
|
11257
11313
|
this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
|
|
11314
|
+
if (typeof this.renderer.circle === 'function') {
|
|
11315
|
+
topPts.forEach((p, i) => {
|
|
11316
|
+
const d = s.values[i];
|
|
11317
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
11318
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
11319
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
11320
|
+
});
|
|
11321
|
+
}
|
|
11258
11322
|
|
|
11259
11323
|
if (seriesIndex === 0) {
|
|
11260
11324
|
const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
|
|
@@ -11419,8 +11483,11 @@
|
|
|
11419
11483
|
this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
|
|
11420
11484
|
|
|
11421
11485
|
pts.forEach(p => {
|
|
11422
|
-
this.renderer.circle
|
|
11423
|
-
|
|
11486
|
+
if (typeof this.renderer.circle === 'function') {
|
|
11487
|
+
this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
|
|
11488
|
+
{ tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
|
|
11489
|
+
{ label: p.d.label, series: isMultiline ? s.name : null });
|
|
11490
|
+
}
|
|
11424
11491
|
});
|
|
11425
11492
|
|
|
11426
11493
|
if (seriesIndex === 0) {
|
|
@@ -11439,35 +11506,104 @@
|
|
|
11439
11506
|
|
|
11440
11507
|
/**
|
|
11441
11508
|
* StackedBarChart — vertical stacked bar chart.
|
|
11509
|
+
*
|
|
11510
|
+
* Accepts either:
|
|
11511
|
+
* - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (the same
|
|
11512
|
+
* shape as `GroupedBarChart`, `LineChart` and `StackedAreaChart`), or
|
|
11513
|
+
* - Flat per-category stacks `Array<{label: string, values: number[]}>` with optional
|
|
11514
|
+
* `options.seriesLabels`.
|
|
11515
|
+
*
|
|
11516
|
+
* Draws a numeric Y axis (`_drawYAxis`), optional series legend (`legend: true`), and records each
|
|
11517
|
+
* segment with `this._datum()` so `onDatumClick` / `PivotChart` drilling works on every segment.
|
|
11442
11518
|
*/
|
|
11443
11519
|
class StackedBarChart extends BarChartBase {
|
|
11444
11520
|
static chartType = 'stackedBar';
|
|
11445
11521
|
|
|
11522
|
+
static _normalize(data, seriesLabels = null) {
|
|
11523
|
+
if (!Array.isArray(data) || data.length === 0) return { categories: [], seriesNames: [] };
|
|
11524
|
+
if (Array.isArray(data[0]?.values) && typeof data[0].values[0] === 'object' && data[0].values[0] !== null) {
|
|
11525
|
+
const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
|
|
11526
|
+
const maps = data.map(s => new Map(s.values.map(v => [v.label, v.value])));
|
|
11527
|
+
return {
|
|
11528
|
+
seriesNames: data.map((s, i) => s.name ?? seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
11529
|
+
categories: labels.map(label => ({ label, values: maps.map(m => m.get(label) ?? 0) })),
|
|
11530
|
+
};
|
|
11531
|
+
}
|
|
11532
|
+
const maxLen = Math.max(0, ...data.map(d => d.values?.length ?? 0));
|
|
11533
|
+
return {
|
|
11534
|
+
seriesNames: Array.from({ length: maxLen }, (_, i) => seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
11535
|
+
categories: data.map(d => ({ label: d.label, values: (d.values ?? []).map(v => v ?? 0) })),
|
|
11536
|
+
};
|
|
11537
|
+
}
|
|
11538
|
+
|
|
11539
|
+
_setup() {
|
|
11540
|
+
this._normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
|
|
11541
|
+
super._setup();
|
|
11542
|
+
if (this.options.legend && this.options.width === undefined && this._normalized.seriesNames.length > 0) {
|
|
11543
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
11544
|
+
}
|
|
11545
|
+
}
|
|
11546
|
+
|
|
11547
|
+
_legendOptions() {
|
|
11548
|
+
return {
|
|
11549
|
+
items: this._normalized.seriesNames.map((name, i) => ({ label: name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
11550
|
+
itemHeight: 20, markerSize: 10,
|
|
11551
|
+
};
|
|
11552
|
+
}
|
|
11553
|
+
|
|
11554
|
+
_isNormalized() {
|
|
11555
|
+
return Boolean(this.options.normalize || this.options.stackMode === 'percent');
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11446
11558
|
_computeMaxValue() {
|
|
11447
|
-
|
|
11448
|
-
|
|
11559
|
+
if (this._isNormalized()) {
|
|
11560
|
+
return this._drawYAxis(100);
|
|
11561
|
+
}
|
|
11562
|
+
const { categories } = this._normalized;
|
|
11563
|
+
const rawMax = Math.max(0, ...categories.map(d => d.values.reduce((sum, val) => sum + Math.max(0, val), 0))) || 1;
|
|
11564
|
+
return this._drawYAxis(rawMax);
|
|
11449
11565
|
}
|
|
11450
11566
|
|
|
11451
11567
|
_renderBars(maxStackValue) {
|
|
11452
|
-
const
|
|
11568
|
+
const { categories, seriesNames } = this._normalized;
|
|
11569
|
+
if (categories.length === 0) return;
|
|
11453
11570
|
const { x, y, width, height } = this._plotBox;
|
|
11454
|
-
const
|
|
11455
|
-
const
|
|
11571
|
+
const normalize = this._isNormalized();
|
|
11572
|
+
const labels = categories.map(d => d.label);
|
|
11573
|
+
const { bandWidth, margin } = this._bandScale(categories.length);
|
|
11456
11574
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
11575
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
11576
|
+
const room = (bandWidth + margin) - 4;
|
|
11577
|
+
const cut = room >= measure('x'.repeat(6));
|
|
11578
|
+
const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
|
|
11457
11579
|
|
|
11458
|
-
|
|
11580
|
+
categories.forEach((d, i) => {
|
|
11459
11581
|
let currentY = y + height;
|
|
11460
11582
|
const barX = x + i * (bandWidth + margin);
|
|
11583
|
+
const colTotal = d.values.reduce((sum, val) => sum + Math.max(0, val ?? 0), 0);
|
|
11461
11584
|
d.values.forEach((value, j) => {
|
|
11462
|
-
|
|
11585
|
+
if (value == null || value <= 0) return;
|
|
11586
|
+
const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
|
|
11587
|
+
const barHeight = (plotted / maxStackValue) * height;
|
|
11463
11588
|
currentY -= barHeight;
|
|
11464
|
-
|
|
11465
|
-
|
|
11466
|
-
|
|
11589
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
11590
|
+
const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
|
|
11591
|
+
this._datum(this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
|
|
11592
|
+
tooltip: `${d.label} - ${series}: ${tipVal}`,
|
|
11593
|
+
}), { label: d.label, series });
|
|
11467
11594
|
});
|
|
11468
|
-
if (i % labelStep === 0)
|
|
11595
|
+
if (i % labelStep === 0) {
|
|
11596
|
+
this.renderer.text(cut ? TableChart.fit(String(d.label), room, measure) : d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
11597
|
+
}
|
|
11469
11598
|
});
|
|
11470
11599
|
}
|
|
11600
|
+
|
|
11601
|
+
_afterBars() {
|
|
11602
|
+
const { x, y, width } = this._plotBox;
|
|
11603
|
+
if (this.options.legend && this._normalized.seriesNames.length > 0) {
|
|
11604
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
11605
|
+
}
|
|
11606
|
+
}
|
|
11471
11607
|
}
|
|
11472
11608
|
|
|
11473
11609
|
// Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
|
|
@@ -11803,8 +11939,8 @@
|
|
|
11803
11939
|
({ from, to } = referenceLine);
|
|
11804
11940
|
}
|
|
11805
11941
|
if (from && to) {
|
|
11806
|
-
const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1 });
|
|
11807
|
-
line
|
|
11942
|
+
const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1, dashArray: [4, 3] });
|
|
11943
|
+
line?.setAttribute?.('stroke-dasharray', '4 3');
|
|
11808
11944
|
}
|
|
11809
11945
|
}
|
|
11810
11946
|
|
|
@@ -11812,7 +11948,10 @@
|
|
|
11812
11948
|
const cx = toX(d.x);
|
|
11813
11949
|
const cy = toY(d.y);
|
|
11814
11950
|
const tooltip = d.label ? `${d.label}: (${d.x}, ${d.y})` : `(${d.x}, ${d.y})`;
|
|
11815
|
-
|
|
11951
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
11952
|
+
this._datum(this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip }), {
|
|
11953
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
11954
|
+
});
|
|
11816
11955
|
|
|
11817
11956
|
const xLow = d.errorXMinus != null ? toX(d.x - d.errorXMinus)
|
|
11818
11957
|
: d.errorX != null ? toX(d.x - d.errorX) : null;
|
|
@@ -11915,7 +12054,10 @@
|
|
|
11915
12054
|
// 3D effect: draw a line to the "floor"
|
|
11916
12055
|
this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
|
|
11917
12056
|
}
|
|
11918
|
-
|
|
12057
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
12058
|
+
this._datum(this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` }), {
|
|
12059
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
12060
|
+
});
|
|
11919
12061
|
});
|
|
11920
12062
|
|
|
11921
12063
|
// Category color legend
|
|
@@ -12094,9 +12236,9 @@
|
|
|
12094
12236
|
const barY = y + height - barHeight;
|
|
12095
12237
|
// No gap between bars (unlike barChart's categorical gaps) — bins
|
|
12096
12238
|
// are contiguous numeric ranges, not independent categories.
|
|
12097
|
-
this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
12239
|
+
this._datum(this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
12098
12240
|
tooltip: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}: ${bin.count}`,
|
|
12099
|
-
});
|
|
12241
|
+
}), { label: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}` });
|
|
12100
12242
|
if (i % Math.ceil(bins.length / 10 || 1) === 0) {
|
|
12101
12243
|
this.renderer.text(Axis.formatValue(bin.from), barX, y + height + 16, { textAnchor: 'start', fontSize: '9px' });
|
|
12102
12244
|
}
|
|
@@ -12198,9 +12340,9 @@
|
|
|
12198
12340
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
|
|
12199
12341
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
|
|
12200
12342
|
const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
|
|
12201
|
-
this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
12343
|
+
this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
12202
12344
|
tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
|
|
12203
|
-
});
|
|
12345
|
+
}), { label: d.label, series: d.label });
|
|
12204
12346
|
this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
|
|
12205
12347
|
s.outliers.forEach(v => {
|
|
12206
12348
|
this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
|
|
@@ -12262,7 +12404,7 @@
|
|
|
12262
12404
|
const topPath = 'M' + topPts.map(p => `${p.px},${p.py}`).join(' L');
|
|
12263
12405
|
const areaPath = topPath + ` L${x + width},${y + height} L${x},${y + height} Z`;
|
|
12264
12406
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
12265
|
-
this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label });
|
|
12407
|
+
this._datum(this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label }), { label: d.label, series: d.label });
|
|
12266
12408
|
this.renderer.path(topPath, 'none', { color, width: 2 });
|
|
12267
12409
|
});
|
|
12268
12410
|
|
|
@@ -12300,9 +12442,9 @@
|
|
|
12300
12442
|
const leftPts = curve.map(p => ({ px: cx - (p.density / maxDensity) * halfWidth, py: this._toY(p.x) }));
|
|
12301
12443
|
const path = 'M' + rightPts.map(p => `${p.px},${p.py}`).join(' L')
|
|
12302
12444
|
+ ' L' + [...leftPts].reverse().map(p => `${p.px},${p.py}`).join(' L') + ' Z';
|
|
12303
|
-
this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
12445
|
+
this._datum(this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
12304
12446
|
tooltip: `${d.label}: n=${d.values.length}`,
|
|
12305
|
-
});
|
|
12447
|
+
}), { label: d.label, series: d.label });
|
|
12306
12448
|
|
|
12307
12449
|
const median = Statistics.quantile([...d.values].sort((a, b) => a - b), 0.5);
|
|
12308
12450
|
this.renderer.path(`M${cx - halfWidth},${this._toY(median)} L${cx + halfWidth},${this._toY(median)}`, 'none', { color: '#fff', width: 2 });
|
|
@@ -12557,6 +12699,34 @@
|
|
|
12557
12699
|
return result;
|
|
12558
12700
|
}
|
|
12559
12701
|
|
|
12702
|
+
/**
|
|
12703
|
+
* Recursive rectangular partition (icicle / flame graph) layout over a node tree.
|
|
12704
|
+
* Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
|
|
12705
|
+
* @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
|
|
12706
|
+
*/
|
|
12707
|
+
static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
|
|
12708
|
+
const items = (nodes ?? [])
|
|
12709
|
+
.map(node => ({ node, value: Hierarchy.value(node) }))
|
|
12710
|
+
.filter(it => it.value > 0);
|
|
12711
|
+
const total = items.reduce((s, it) => s + it.value, 0) || 1;
|
|
12712
|
+
|
|
12713
|
+
const result = [];
|
|
12714
|
+
let cursorX = x;
|
|
12715
|
+
for (const it of items) {
|
|
12716
|
+
const { node } = it;
|
|
12717
|
+
const spanW = (it.value / total) * width;
|
|
12718
|
+
const isLeaf = !node.children?.length;
|
|
12719
|
+
const topAncestor = topAncestors ? topAncestors.get(node) : node;
|
|
12720
|
+
result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
|
|
12721
|
+
if (!isLeaf) {
|
|
12722
|
+
const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
|
|
12723
|
+
result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
|
|
12724
|
+
}
|
|
12725
|
+
cursorX += spanW;
|
|
12726
|
+
}
|
|
12727
|
+
return result;
|
|
12728
|
+
}
|
|
12729
|
+
|
|
12560
12730
|
/**
|
|
12561
12731
|
* Flat, pre-order list of *visible* rows for an outline view. A branch
|
|
12562
12732
|
* present in `collapsed` contributes its own row but none of its
|
|
@@ -12646,9 +12816,9 @@
|
|
|
12646
12816
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
12647
12817
|
|
|
12648
12818
|
if (isLeaf) {
|
|
12649
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
12819
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
12650
12820
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
12651
|
-
});
|
|
12821
|
+
}), { label: node.label });
|
|
12652
12822
|
if (rw > 30 && rh > 14) {
|
|
12653
12823
|
this.renderer.text(node.label, rx + rw / 2, ry + rh / 2, {
|
|
12654
12824
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
@@ -12657,9 +12827,9 @@
|
|
|
12657
12827
|
} else {
|
|
12658
12828
|
const headerH = Math.min(rh, TreemapChart.HEADER_H);
|
|
12659
12829
|
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), 'rgba(0,0,0,0.03)', { color: '#ddd', width: 1 });
|
|
12660
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
12830
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
12661
12831
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
12662
|
-
});
|
|
12832
|
+
}), { label: node.label });
|
|
12663
12833
|
if (rw > 30) {
|
|
12664
12834
|
this.renderer.text(node.label, rx + 4, ry + headerH / 2, {
|
|
12665
12835
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: textColor, 'pointer-events': 'none',
|
|
@@ -12707,9 +12877,9 @@
|
|
|
12707
12877
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
12708
12878
|
|
|
12709
12879
|
const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
|
|
12710
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
12880
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
12711
12881
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
12712
|
-
});
|
|
12882
|
+
}), { label: node.label });
|
|
12713
12883
|
|
|
12714
12884
|
if (endAngle - startAngle > 0.35) {
|
|
12715
12885
|
const midAngle = (startAngle + endAngle) / 2;
|
|
@@ -12759,15 +12929,16 @@
|
|
|
12759
12929
|
const accentColor = this._colorForNode(topAncestor, depth);
|
|
12760
12930
|
|
|
12761
12931
|
const g = this.renderer.group();
|
|
12762
|
-
|
|
12932
|
+
const addChild = (child) => g?.appendChild?.(child);
|
|
12933
|
+
addChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
|
|
12763
12934
|
|
|
12764
12935
|
let labelX = indentX + 16;
|
|
12765
12936
|
if (hasChildren) {
|
|
12766
12937
|
const collapsedHere = this.collapsed ? this.collapsed.has(node) : false;
|
|
12767
12938
|
const badgeX = indentX + 26;
|
|
12768
12939
|
const badge = this.renderer.circle(badgeX, rowCenterY, 7, '#ffffff', { color: '#888', width: 1.2 });
|
|
12769
|
-
|
|
12770
|
-
|
|
12940
|
+
addChild(badge);
|
|
12941
|
+
addChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
|
|
12771
12942
|
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#555', 'pointer-events': 'none',
|
|
12772
12943
|
}));
|
|
12773
12944
|
badge.style.cursor = 'pointer';
|
|
@@ -12781,22 +12952,23 @@
|
|
|
12781
12952
|
labelX = badgeX + 16;
|
|
12782
12953
|
}
|
|
12783
12954
|
|
|
12784
|
-
|
|
12955
|
+
const labelEl = this._datum(this.renderer.text(node.label, labelX, rowCenterY, {
|
|
12785
12956
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#222',
|
|
12786
|
-
}));
|
|
12957
|
+
}), { label: node.label, series: null });
|
|
12958
|
+
addChild(labelEl);
|
|
12787
12959
|
|
|
12788
12960
|
if (hasChildren) {
|
|
12789
|
-
|
|
12961
|
+
addChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
|
|
12790
12962
|
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
12791
12963
|
}));
|
|
12792
12964
|
} else if (Number.isFinite(node.value)) {
|
|
12793
|
-
|
|
12965
|
+
addChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
|
|
12794
12966
|
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
12795
12967
|
}));
|
|
12796
12968
|
} else {
|
|
12797
12969
|
const extra = Hierarchy.outlineExtraText(node, structuralKeys);
|
|
12798
12970
|
if (extra) {
|
|
12799
|
-
|
|
12971
|
+
addChild(this.renderer.text(extra, detailX, rowCenterY, {
|
|
12800
12972
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
12801
12973
|
}));
|
|
12802
12974
|
}
|
|
@@ -12818,6 +12990,308 @@
|
|
|
12818
12990
|
OutlineChart.INDENT_W = 18;
|
|
12819
12991
|
OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
|
|
12820
12992
|
|
|
12993
|
+
/**
|
|
12994
|
+
* IcicleChart — rectangular hierarchical partition chart (top-down flame/icicle layout).
|
|
12995
|
+
* Each depth level forms a horizontal band and each node's width is proportional to its value
|
|
12996
|
+
* within its parent's span.
|
|
12997
|
+
*/
|
|
12998
|
+
class IcicleChart extends HierarchyChart {
|
|
12999
|
+
static chartType = 'icicle';
|
|
13000
|
+
|
|
13001
|
+
_render() {
|
|
13002
|
+
const data = this.data ?? [];
|
|
13003
|
+
const options = this.options;
|
|
13004
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
13005
|
+
const { x = box.x, y = box.y, width = box.width, height = box.height, title } = options;
|
|
13006
|
+
const plotBox = { x, y, width, height };
|
|
13007
|
+
|
|
13008
|
+
new ChartLabels({ title }).draw(this.renderer, plotBox);
|
|
13009
|
+
if (data.length === 0) return;
|
|
13010
|
+
|
|
13011
|
+
const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
|
|
13012
|
+
const bandHeight = height / maxDepth;
|
|
13013
|
+
const layout = Hierarchy.icicleLayout(data, x, y, width, bandHeight);
|
|
13014
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
13015
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '10px' }) ?? s.length * 10 * 0.55;
|
|
13016
|
+
|
|
13017
|
+
layout.forEach(entry => {
|
|
13018
|
+
const { node, x: rx, y: ry, width: rw, height: rh, depth, topAncestor } = entry;
|
|
13019
|
+
const color = this._colorForNode(topAncestor, depth);
|
|
13020
|
+
const textColor = this._textColorForNode(topAncestor, depth);
|
|
13021
|
+
|
|
13022
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
13023
|
+
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
13024
|
+
}), { label: node.label });
|
|
13025
|
+
|
|
13026
|
+
if (rw > 28 && rh > 14) {
|
|
13027
|
+
const fitted = TableChart.fit(String(node.label), rw - 6, measure);
|
|
13028
|
+
this.renderer.text(fitted, rx + rw / 2, ry + rh / 2, {
|
|
13029
|
+
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
13030
|
+
});
|
|
13031
|
+
}
|
|
13032
|
+
});
|
|
13033
|
+
}
|
|
13034
|
+
}
|
|
13035
|
+
|
|
13036
|
+
/**
|
|
13037
|
+
* ParetoChart — descending bar chart combined with a cumulative percentage line (0–100%)
|
|
13038
|
+
* on a right-hand axis and an 80% cutoff reference line.
|
|
13039
|
+
*/
|
|
13040
|
+
class ParetoChart extends BarChartBase {
|
|
13041
|
+
static chartType = 'pareto';
|
|
13042
|
+
|
|
13043
|
+
_setup() {
|
|
13044
|
+
super._setup();
|
|
13045
|
+
if (this.options.width === undefined) {
|
|
13046
|
+
this._plotBox.width = Math.max(80, this._plotBox.width - 36);
|
|
13047
|
+
}
|
|
13048
|
+
const items = (this.data ?? []).filter(d => d && typeof d.value === 'number' && d.value >= 0);
|
|
13049
|
+
this._sorted = this.options.sorted === false ? [...items] : [...items].sort((a, b) => b.value - a.value);
|
|
13050
|
+
}
|
|
13051
|
+
|
|
13052
|
+
_computeMaxValue() {
|
|
13053
|
+
const rawMax = Math.max(0, ...this._sorted.map(d => d.value)) || 1;
|
|
13054
|
+
return this._drawYAxis(rawMax, 0);
|
|
13055
|
+
}
|
|
13056
|
+
|
|
13057
|
+
_renderBars(maxValue) {
|
|
13058
|
+
const items = this._sorted;
|
|
13059
|
+
if (items.length === 0) return;
|
|
13060
|
+
const { x, y, width, height } = this._plotBox;
|
|
13061
|
+
const {
|
|
13062
|
+
barColor = '#3b82f6',
|
|
13063
|
+
lineColor = '#dc2626',
|
|
13064
|
+
cutoff = 80,
|
|
13065
|
+
colors,
|
|
13066
|
+
} = this.options;
|
|
13067
|
+
const { bandWidth, margin } = this._bandScale(items.length);
|
|
13068
|
+
const labelStep = Axis.categoryLabelStep(items.map(d => d.label), width);
|
|
13069
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
13070
|
+
const total = items.reduce((sum, d) => sum + d.value, 0) || 1;
|
|
13071
|
+
|
|
13072
|
+
// Right-hand percentage axis (0%..100%).
|
|
13073
|
+
const rightX = x + width;
|
|
13074
|
+
this.renderer.path(`M${rightX},${y} L${rightX},${y + height}`, 'none', { color: '#64748b', width: 1 });
|
|
13075
|
+
for (const pct of [0, 20, 40, 60, 80, 100]) {
|
|
13076
|
+
const py = y + height - (pct / 100) * height;
|
|
13077
|
+
this.renderer.path(`M${rightX},${py} L${rightX + 4},${py}`, 'none', { color: '#64748b', width: 1 });
|
|
13078
|
+
this.renderer.text(`${pct}%`, rightX + 8, py + 3, { textAnchor: 'start', fontSize: '10px', fill: '#475569' });
|
|
13079
|
+
}
|
|
13080
|
+
|
|
13081
|
+
// Dashed 80% cutoff reference line.
|
|
13082
|
+
if (cutoff > 0 && cutoff < 100) {
|
|
13083
|
+
const cutY = y + height - (cutoff / 100) * height;
|
|
13084
|
+
this.renderer.path(`M${x},${cutY} L${rightX},${cutY}`, 'none', {
|
|
13085
|
+
color: lineColor, width: 1, dashArray: '4,4',
|
|
13086
|
+
});
|
|
13087
|
+
}
|
|
13088
|
+
|
|
13089
|
+
let running = 0;
|
|
13090
|
+
const linePts = [];
|
|
13091
|
+
items.forEach((d, i) => {
|
|
13092
|
+
running += d.value;
|
|
13093
|
+
const cumPct = (running / total) * 100;
|
|
13094
|
+
const color = colors ? this._palette.colorAt(i) : barColor;
|
|
13095
|
+
const bar = this._barSpan(d.value);
|
|
13096
|
+
const barX = x + i * (bandWidth + margin);
|
|
13097
|
+
const cx = barX + bandWidth / 2;
|
|
13098
|
+
const py = y + height - (cumPct / 100) * height;
|
|
13099
|
+
linePts.push({ cx, py, label: d.label, value: d.value, cumPct });
|
|
13100
|
+
|
|
13101
|
+
this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, {
|
|
13102
|
+
bar: true,
|
|
13103
|
+
dataIndex: i,
|
|
13104
|
+
tooltip: `${d.label}: ${d.value} (${cumPct.toFixed(1)}% cumulative)`,
|
|
13105
|
+
}), { label: d.label });
|
|
13106
|
+
|
|
13107
|
+
if (i % labelStep === 0) {
|
|
13108
|
+
this.renderer.text(d.label, cx, y + height + 20, { textAnchor });
|
|
13109
|
+
}
|
|
13110
|
+
});
|
|
13111
|
+
|
|
13112
|
+
if (linePts.length > 0) {
|
|
13113
|
+
const dPath = 'M' + linePts.map(p => `${p.cx},${p.py}`).join(' L');
|
|
13114
|
+
this.renderer.path(dPath, 'none', { color: lineColor, width: 2 });
|
|
13115
|
+
if (typeof this.renderer.circle === 'function') {
|
|
13116
|
+
linePts.forEach(p => {
|
|
13117
|
+
this._datum(this.renderer.circle(p.cx, p.py, 3.5, lineColor, { color: '#fff', width: 1.5 }, {
|
|
13118
|
+
tooltip: `${p.label}: ${p.cumPct.toFixed(1)}% cumulative`,
|
|
13119
|
+
}), { label: p.label });
|
|
13120
|
+
});
|
|
13121
|
+
}
|
|
13122
|
+
}
|
|
13123
|
+
}
|
|
13124
|
+
}
|
|
13125
|
+
|
|
13126
|
+
/**
|
|
13127
|
+
* LollipopChart — stem-and-dot alternative to a bar chart for high-cardinality or multi-series
|
|
13128
|
+
* categorical comparisons with minimal ink density.
|
|
13129
|
+
*
|
|
13130
|
+
* Accepts either:
|
|
13131
|
+
* - 1D `Array<{label: string, value: number}>`, or
|
|
13132
|
+
* - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>`.
|
|
13133
|
+
*/
|
|
13134
|
+
class LollipopChart extends BarChartBase {
|
|
13135
|
+
static chartType = 'lollipop';
|
|
13136
|
+
|
|
13137
|
+
static _normalize(data) {
|
|
13138
|
+
if (!Array.isArray(data) || data.length === 0) return { isMulti: false, series: [], labels: [] };
|
|
13139
|
+
if (Array.isArray(data[0]?.values)) {
|
|
13140
|
+
const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
|
|
13141
|
+
return { isMulti: true, series: data, labels };
|
|
13142
|
+
}
|
|
13143
|
+
return {
|
|
13144
|
+
isMulti: false,
|
|
13145
|
+
series: [{ name: 'default', values: data }],
|
|
13146
|
+
labels: data.map(d => d.label),
|
|
13147
|
+
};
|
|
13148
|
+
}
|
|
13149
|
+
|
|
13150
|
+
_setup() {
|
|
13151
|
+
this._norm = LollipopChart._normalize(this.data);
|
|
13152
|
+
super._setup();
|
|
13153
|
+
const showLegend = this.options.legend ?? this._norm.isMulti;
|
|
13154
|
+
if (showLegend && this._norm.isMulti && this.options.width === undefined) {
|
|
13155
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
13156
|
+
}
|
|
13157
|
+
}
|
|
13158
|
+
|
|
13159
|
+
_legendOptions() {
|
|
13160
|
+
return {
|
|
13161
|
+
items: this._norm.series.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
13162
|
+
itemHeight: 20, markerSize: 10,
|
|
13163
|
+
};
|
|
13164
|
+
}
|
|
13165
|
+
|
|
13166
|
+
_computeMaxValue() {
|
|
13167
|
+
const allValues = this._norm.series.flatMap(s => s.values.map(v => v.value)).filter(v => typeof v === 'number');
|
|
13168
|
+
const rawMax = Math.max(0, ...allValues) || 1;
|
|
13169
|
+
const rawMin = Math.min(0, ...allValues);
|
|
13170
|
+
return this._drawYAxis(rawMax, rawMin);
|
|
13171
|
+
}
|
|
13172
|
+
|
|
13173
|
+
_renderBars() {
|
|
13174
|
+
const { isMulti, series, labels } = this._norm;
|
|
13175
|
+
if (labels.length === 0) return;
|
|
13176
|
+
const { x, y, width, height } = this._plotBox;
|
|
13177
|
+
const {
|
|
13178
|
+
color: singleColor = '#2563eb',
|
|
13179
|
+
colors,
|
|
13180
|
+
radius = 5,
|
|
13181
|
+
stemWidth = 2,
|
|
13182
|
+
} = this.options;
|
|
13183
|
+
const { bandWidth, margin } = this._bandScale(labels.length);
|
|
13184
|
+
const labelStep = Axis.categoryLabelStep(labels, width);
|
|
13185
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
13186
|
+
const zeroY = this._barSpan(0).end;
|
|
13187
|
+
const seriesCount = series.length;
|
|
13188
|
+
|
|
13189
|
+
labels.forEach((label, i) => {
|
|
13190
|
+
const bandX = x + i * (bandWidth + margin);
|
|
13191
|
+
series.forEach((s, sIdx) => {
|
|
13192
|
+
const pt = s.values.find(v => v.label === label);
|
|
13193
|
+
if (!pt || typeof pt.value !== 'number') return;
|
|
13194
|
+
const cx = seriesCount === 1
|
|
13195
|
+
? bandX + bandWidth / 2
|
|
13196
|
+
: bandX + ((sIdx + 0.5) / seriesCount) * bandWidth;
|
|
13197
|
+
const span = this._barSpan(pt.value);
|
|
13198
|
+
const col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
|
|
13199
|
+
const tip = isMulti ? `${s.name} - ${label}: ${pt.value}` : `${label}: ${pt.value}`;
|
|
13200
|
+
|
|
13201
|
+
this.renderer.path(`M${cx},${zeroY} L${cx},${span.end}`, 'none', { color: col, width: stemWidth });
|
|
13202
|
+
if (typeof this.renderer.circle === 'function') {
|
|
13203
|
+
this._datum(this.renderer.circle(cx, span.end, radius, col, { color: '#fff', width: 1.5 }, {
|
|
13204
|
+
tooltip: tip,
|
|
13205
|
+
}), { label, series: isMulti ? s.name : null });
|
|
13206
|
+
} else {
|
|
13207
|
+
this._datum(this.renderer.rect(cx - radius, span.end - radius, radius * 2, radius * 2, col, null, {
|
|
13208
|
+
tooltip: tip,
|
|
13209
|
+
}), { label, series: isMulti ? s.name : null });
|
|
13210
|
+
}
|
|
13211
|
+
});
|
|
13212
|
+
if (i % labelStep === 0) {
|
|
13213
|
+
this.renderer.text(label, bandX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
13214
|
+
}
|
|
13215
|
+
});
|
|
13216
|
+
}
|
|
13217
|
+
|
|
13218
|
+
_afterBars() {
|
|
13219
|
+
const showLegend = this.options.legend ?? this._norm.isMulti;
|
|
13220
|
+
if (showLegend && this._norm.isMulti) {
|
|
13221
|
+
const { x, y, width } = this._plotBox;
|
|
13222
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
13223
|
+
}
|
|
13224
|
+
}
|
|
13225
|
+
}
|
|
13226
|
+
|
|
13227
|
+
/**
|
|
13228
|
+
* MarimekkoChart — two-dimensional 100%-stacked mosaic chart where column width is proportional to
|
|
13229
|
+
* each category's total across series and segment height is each series' percentage share within
|
|
13230
|
+
* that category.
|
|
13231
|
+
*/
|
|
13232
|
+
class MarimekkoChart extends Chart {
|
|
13233
|
+
static chartType = 'marimekko';
|
|
13234
|
+
|
|
13235
|
+
_render() {
|
|
13236
|
+
const normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
|
|
13237
|
+
const { categories, seriesNames } = normalized;
|
|
13238
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
13239
|
+
const {
|
|
13240
|
+
x = box.x, y = box.y, width: rawWidth = box.width, height = box.height,
|
|
13241
|
+
colors, title, xAxisLabel, yAxisLabel, legend: showLegend = true,
|
|
13242
|
+
} = this.options;
|
|
13243
|
+
const palette = new ColorPalette(colors);
|
|
13244
|
+
const legendOpts = {
|
|
13245
|
+
items: seriesNames.map((name, i) => ({ label: name, color: palette.colorAt(i), dataIndex: i })),
|
|
13246
|
+
itemHeight: 20, markerSize: 10,
|
|
13247
|
+
};
|
|
13248
|
+
const width = showLegend && this.options.width === undefined && seriesNames.length > 0
|
|
13249
|
+
? Legend.plotWidthBeside({ x, y, width: rawWidth, height }, this.renderer.width, legendOpts)
|
|
13250
|
+
: rawWidth;
|
|
13251
|
+
const plotBox = { x, y, width, height };
|
|
13252
|
+
|
|
13253
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
13254
|
+
new Axis().drawYRange(this.renderer, plotBox, 0, 100);
|
|
13255
|
+
|
|
13256
|
+
const colTotals = categories.map(c => c.values.reduce((sum, v) => sum + Math.max(0, v ?? 0), 0));
|
|
13257
|
+
const grandTotal = colTotals.reduce((sum, v) => sum + v, 0) || 1;
|
|
13258
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
13259
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '11px' }) ?? s.length * 11 * 0.55;
|
|
13260
|
+
|
|
13261
|
+
let cursorX = x;
|
|
13262
|
+
categories.forEach((cat, i) => {
|
|
13263
|
+
const colTotal = colTotals[i];
|
|
13264
|
+
if (colTotal <= 0) return;
|
|
13265
|
+
const colW = (colTotal / grandTotal) * width;
|
|
13266
|
+
let cursorY = y + height;
|
|
13267
|
+
|
|
13268
|
+
cat.values.forEach((value, j) => {
|
|
13269
|
+
if (value == null || value <= 0) return;
|
|
13270
|
+
const share = (value / colTotal) * 100;
|
|
13271
|
+
const segH = (share / 100) * height;
|
|
13272
|
+
cursorY -= segH;
|
|
13273
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
13274
|
+
const color = palette.colorAt(j);
|
|
13275
|
+
this._datum(this.renderer.rect(cursorX, cursorY, colW, segH, color, { color: '#fff', width: 1 }, {
|
|
13276
|
+
tooltip: `${cat.label} - ${series}: ${value} (${share.toFixed(1)}%)`,
|
|
13277
|
+
}), { label: cat.label, series });
|
|
13278
|
+
});
|
|
13279
|
+
|
|
13280
|
+
if (colW >= 24) {
|
|
13281
|
+
const labelText = TableChart.fit(String(cat.label), colW - 4, measure);
|
|
13282
|
+
this.renderer.text(labelText, cursorX + colW / 2, y + height + 18, { textAnchor, fontSize: '11px' });
|
|
13283
|
+
}
|
|
13284
|
+
cursorX += colW;
|
|
13285
|
+
});
|
|
13286
|
+
|
|
13287
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
13288
|
+
|
|
13289
|
+
if (showLegend && seriesNames.length > 0) {
|
|
13290
|
+
Legend.beside({ x, y, width }, this.renderer.width, legendOpts).draw(this.renderer);
|
|
13291
|
+
}
|
|
13292
|
+
}
|
|
13293
|
+
}
|
|
13294
|
+
|
|
12821
13295
|
/**
|
|
12822
13296
|
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
12823
13297
|
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
@@ -13551,6 +14025,16 @@
|
|
|
13551
14025
|
class PivotView {
|
|
13552
14026
|
static ROLLUP_COLUMN = '_rollup';
|
|
13553
14027
|
|
|
14028
|
+
static AGGREGATIONS = [
|
|
14029
|
+
{ key: 'sum', label: 'Sum' },
|
|
14030
|
+
{ key: 'count', label: 'Count' },
|
|
14031
|
+
{ key: 'avg', label: 'Average' },
|
|
14032
|
+
{ key: 'median', label: 'Median' },
|
|
14033
|
+
{ key: 'min', label: 'Minimum' },
|
|
14034
|
+
{ key: 'max', label: 'Maximum' },
|
|
14035
|
+
{ key: 'stddev', label: 'Std Dev' },
|
|
14036
|
+
];
|
|
14037
|
+
|
|
13554
14038
|
/**
|
|
13555
14039
|
* @param {Array<object>} rows result rows: dimension values, measure values, and `_rollup`
|
|
13556
14040
|
* @param {object} [options]
|
|
@@ -13578,6 +14062,7 @@
|
|
|
13578
14062
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
13579
14063
|
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
13580
14064
|
* measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
|
|
14065
|
+
* @param {string[]} [options.visibleMeasures] subset of `measures` currently shown in the grid and chart
|
|
13581
14066
|
*/
|
|
13582
14067
|
constructor(rows, options = {}) {
|
|
13583
14068
|
this.onChange = options.onChange ?? null;
|
|
@@ -13593,6 +14078,18 @@
|
|
|
13593
14078
|
this.lightRules = options.trafficLights ?? null;
|
|
13594
14079
|
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
13595
14080
|
this.lights = this.lightRules ? 'rules' : null;
|
|
14081
|
+
/** Optional sort on the row axis: `{ columnKey, measure, direction: 'asc'|'desc' }`, or null. */
|
|
14082
|
+
this.rowSort = null;
|
|
14083
|
+
/** Optional Top-N / Bottom-N filter on row siblings: `{ count, columnKey, measure, direction: 'desc'|'asc' }`, or null. */
|
|
14084
|
+
this.topN = null;
|
|
14085
|
+
/** Post-rollup derived formula measures: `Map<name, { name, formula, params, fn }>`. */
|
|
14086
|
+
this.derivedMeasures = new Map();
|
|
14087
|
+
/** Summary / aggregation mode: `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`. */
|
|
14088
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === options.aggregation) ? options.aggregation : 'sum';
|
|
14089
|
+
/** Optional raw fact rows behind the cube, enabling exact cell-level Count, Average, Median, Min, Max, and Std Dev. */
|
|
14090
|
+
this.facts = Array.isArray(options.facts) ? options.facts : null;
|
|
14091
|
+
/** How cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
14092
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(options.valueMode) ? options.valueMode : 'value';
|
|
13596
14093
|
this._load(rows, options);
|
|
13597
14094
|
}
|
|
13598
14095
|
|
|
@@ -13609,6 +14106,10 @@
|
|
|
13609
14106
|
_load(rows, options) {
|
|
13610
14107
|
this._options = options;
|
|
13611
14108
|
this.rows = rows;
|
|
14109
|
+
if (Array.isArray(options.facts)) this.facts = options.facts;
|
|
14110
|
+
if (options.aggregation && PivotView.AGGREGATIONS.some(a => a.key === options.aggregation)) {
|
|
14111
|
+
this.aggregation = options.aggregation;
|
|
14112
|
+
}
|
|
13612
14113
|
this.rowAxis = [...(options.rowAxis ?? [])];
|
|
13613
14114
|
this.columnAxis = [...(options.columnAxis ?? [])];
|
|
13614
14115
|
this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
|
|
@@ -13617,9 +14118,42 @@
|
|
|
13617
14118
|
const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
|
|
13618
14119
|
this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
|
|
13619
14120
|
this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
|
|
13620
|
-
|
|
14121
|
+
if (Array.isArray(options.derivedMeasures)) {
|
|
14122
|
+
this.derivedMeasures.clear();
|
|
14123
|
+
for (const spec of options.derivedMeasures) {
|
|
14124
|
+
if (spec?.name && spec.formula) this.derivedMeasures.set(spec.name, PivotView._compileDerived(spec));
|
|
14125
|
+
}
|
|
14126
|
+
}
|
|
14127
|
+
const baseMeasures = options.measures ?? this._defaultMeasures(rows, options.columns);
|
|
14128
|
+
const derivedNames = [...this.derivedMeasures.keys()].filter(m => !baseMeasures.includes(m));
|
|
14129
|
+
this.allMeasures = [...baseMeasures, ...derivedNames];
|
|
14130
|
+
const keptMeasures = (options.visibleMeasures ?? this.measures ?? this.allMeasures).filter(m => this.allMeasures.includes(m));
|
|
14131
|
+
this.measures = keptMeasures.length > 0 ? keptMeasures : [...this.allMeasures];
|
|
13621
14132
|
this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
|
|
13622
14133
|
this._index();
|
|
14134
|
+
if (options.topN && options.topN.count > 0) {
|
|
14135
|
+
this.topN = {
|
|
14136
|
+
count: options.topN.count,
|
|
14137
|
+
columnKey: options.topN.columnKey ?? this.columnRoot.key,
|
|
14138
|
+
measure: options.topN.measure ?? this.measures[0],
|
|
14139
|
+
direction: options.topN.direction === 'asc' ? 'asc' : 'desc',
|
|
14140
|
+
};
|
|
14141
|
+
}
|
|
14142
|
+
}
|
|
14143
|
+
|
|
14144
|
+
static _compileDerived({ name, formula, params = null }) {
|
|
14145
|
+
if (typeof formula === 'function') {
|
|
14146
|
+
return { name, formula, params, fn: (row) => formula(row) };
|
|
14147
|
+
}
|
|
14148
|
+
const builtins = new Set(['abs', 'min', 'max', 'sqrt', 'log', 'exp', 'floor', 'ceil', 'round', 'clamp']);
|
|
14149
|
+
const paramList = Array.isArray(params) && params.length > 0
|
|
14150
|
+
? params
|
|
14151
|
+
: [...new Set((String(formula).match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter(id => !builtins.has(id)))];
|
|
14152
|
+
const compiled = compileFormula(String(formula), paramList);
|
|
14153
|
+
const fn = (row) => (paramList.some(p => typeof row[p] !== 'number')
|
|
14154
|
+
? null
|
|
14155
|
+
: compiled(...paramList.map(p => row[p])));
|
|
14156
|
+
return { name, formula, params: paramList, fn };
|
|
13623
14157
|
}
|
|
13624
14158
|
|
|
13625
14159
|
_defaultMeasures(rows, columns) {
|
|
@@ -13651,6 +14185,7 @@
|
|
|
13651
14185
|
|
|
13652
14186
|
_index() {
|
|
13653
14187
|
this.cells = new Map();
|
|
14188
|
+
this._sampleCache = new Map();
|
|
13654
14189
|
this.unplaced = 0;
|
|
13655
14190
|
this.rowRoot = PivotView._node([], null);
|
|
13656
14191
|
this.columnRoot = PivotView._node([], null);
|
|
@@ -13699,12 +14234,35 @@
|
|
|
13699
14234
|
|
|
13700
14235
|
/**
|
|
13701
14236
|
* The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
|
|
13702
|
-
* members below it, indented; the grand total last. `{ node, depth }`.
|
|
14237
|
+
* members below it, indented; the grand total last. `{ node, depth }`. When `rowSort` is set,
|
|
14238
|
+
* siblings at each level are ordered by that column/measure (nulls last).
|
|
13703
14239
|
*/
|
|
13704
14240
|
visibleRows() {
|
|
13705
14241
|
const out = [];
|
|
14242
|
+
const sortList = (list, spec) => {
|
|
14243
|
+
const { columnKey, measure, direction } = spec;
|
|
14244
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
14245
|
+
const colNode = this._findColumnNode(columnKey) ?? this.columnRoot;
|
|
14246
|
+
const col = { node: colNode, measure: this.measures.includes(measure) ? measure : this.measures[0] };
|
|
14247
|
+
return [...list].sort((a, b) => {
|
|
14248
|
+
const va = this.value(a, col), vb = this.value(b, col);
|
|
14249
|
+
if (va == null) return vb == null ? 0 : 1;
|
|
14250
|
+
if (vb == null) return -1;
|
|
14251
|
+
return sign * (va - vb);
|
|
14252
|
+
});
|
|
14253
|
+
};
|
|
14254
|
+
const order = (children) => {
|
|
14255
|
+
let list = [...children.values()];
|
|
14256
|
+
if (this.topN && this.topN.count > 0) {
|
|
14257
|
+
list = sortList(list, this.topN).slice(0, this.topN.count);
|
|
14258
|
+
}
|
|
14259
|
+
if (this.rowSort) {
|
|
14260
|
+
list = sortList(list, this.rowSort);
|
|
14261
|
+
}
|
|
14262
|
+
return list;
|
|
14263
|
+
};
|
|
13706
14264
|
const walk = (node) => {
|
|
13707
|
-
for (const child of node.children
|
|
14265
|
+
for (const child of order(node.children)) {
|
|
13708
14266
|
out.push({ node: child, depth: child.path.length - 1 });
|
|
13709
14267
|
if (!this.isCollapsed('rows', child)) walk(child);
|
|
13710
14268
|
}
|
|
@@ -13714,6 +14272,19 @@
|
|
|
13714
14272
|
return out;
|
|
13715
14273
|
}
|
|
13716
14274
|
|
|
14275
|
+
_findColumnNode(key) {
|
|
14276
|
+
if (!key || key === this.columnRoot.key) return this.columnRoot;
|
|
14277
|
+
const walk = (node) => {
|
|
14278
|
+
for (const child of node.children.values()) {
|
|
14279
|
+
if (child.key === key) return child;
|
|
14280
|
+
const found = walk(child);
|
|
14281
|
+
if (found) return found;
|
|
14282
|
+
}
|
|
14283
|
+
return null;
|
|
14284
|
+
};
|
|
14285
|
+
return walk(this.columnRoot);
|
|
14286
|
+
}
|
|
14287
|
+
|
|
13717
14288
|
/**
|
|
13718
14289
|
* The value columns drawn, left to right: each column-axis member's members (unless it is
|
|
13719
14290
|
* collapsed, or has none), then its own total; the grand total last. One per measure for each.
|
|
@@ -13781,10 +14352,140 @@
|
|
|
13781
14352
|
return out.map(({ id, ...cell }) => cell);
|
|
13782
14353
|
}
|
|
13783
14354
|
|
|
13784
|
-
/**
|
|
14355
|
+
/** Evaluates `measure` on a single data or fact row (handling derived formulas). */
|
|
14356
|
+
_rowMeasureValue(row, measure) {
|
|
14357
|
+
if (!row) return null;
|
|
14358
|
+
if (this.derivedMeasures.has(measure)) {
|
|
14359
|
+
let scope = row;
|
|
14360
|
+
if (this.derivedMeasures.size > 1) {
|
|
14361
|
+
scope = { ...row };
|
|
14362
|
+
for (const [name, d] of this.derivedMeasures) {
|
|
14363
|
+
const val = d.fn(scope);
|
|
14364
|
+
scope[name] = Number.isFinite(val) ? Math.round(val * 1000) / 1000 : null;
|
|
14365
|
+
if (name === measure) return scope[name];
|
|
14366
|
+
}
|
|
14367
|
+
}
|
|
14368
|
+
const d = this.derivedMeasures.get(measure);
|
|
14369
|
+
const v = d.fn(scope);
|
|
14370
|
+
return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
|
|
14371
|
+
}
|
|
14372
|
+
return row[measure] ?? null;
|
|
14373
|
+
}
|
|
14374
|
+
|
|
14375
|
+
/**
|
|
14376
|
+
* Collects the sample of numeric values behind `(rowNode, colNode, measure)` for statistical
|
|
14377
|
+
* aggregations (`count`, `avg`, `median`, `min`, `max`, `stddev`):
|
|
14378
|
+
* 1) From `this.facts` when raw fact rows are supplied,
|
|
14379
|
+
* 2) Else from `row._values?.[measure]` when pre-attached,
|
|
14380
|
+
* 3) Else from the descendant leaf cells in `this.cells`.
|
|
14381
|
+
*/
|
|
14382
|
+
_cellSample(rowNode, colNode, measure) {
|
|
14383
|
+
const cacheKey = `${rowNode.key}|${colNode.key}|${measure}`;
|
|
14384
|
+
if (this._sampleCache?.has(cacheKey)) return this._sampleCache.get(cacheKey);
|
|
14385
|
+
let values = [];
|
|
14386
|
+
const row = this.cell(rowNode, colNode);
|
|
14387
|
+
if (Array.isArray(this.facts) && this.facts.length > 0) {
|
|
14388
|
+
const matching = this.facts.filter(f => rowNode.path.every((v, i) => f[this.rowAxis[i]] === v)
|
|
14389
|
+
&& colNode.path.every((v, j) => f[this.columnAxis[j]] === v)
|
|
14390
|
+
&& this.pageAxis.every(p => p.value === null || String(f[p.dimension]) === String(p.value))
|
|
14391
|
+
&& this.memberFilters.every(m => (m.keep ? m.keep.includes(f[m.dimension]) : !m.remove?.includes(f[m.dimension]))));
|
|
14392
|
+
values = matching.map(f => this._rowMeasureValue(f, measure)).filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
14393
|
+
} else if (Array.isArray(row?._values?.[measure])) {
|
|
14394
|
+
values = row._values[measure].filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
14395
|
+
} else {
|
|
14396
|
+
const leaves = (node) => {
|
|
14397
|
+
if (node.children.size === 0) return [node];
|
|
14398
|
+
const out = [];
|
|
14399
|
+
for (const c of node.children.values()) out.push(...leaves(c));
|
|
14400
|
+
return out;
|
|
14401
|
+
};
|
|
14402
|
+
const rLeaves = leaves(rowNode);
|
|
14403
|
+
const cLeaves = leaves(colNode);
|
|
14404
|
+
for (const r of rLeaves) {
|
|
14405
|
+
for (const c of cLeaves) {
|
|
14406
|
+
const leafRow = this.cell(r, c);
|
|
14407
|
+
const v = this._rowMeasureValue(leafRow, measure);
|
|
14408
|
+
if (typeof v === 'number' && Number.isFinite(v)) values.push(v);
|
|
14409
|
+
}
|
|
14410
|
+
}
|
|
14411
|
+
}
|
|
14412
|
+
this._sampleCache?.set(cacheKey, values);
|
|
14413
|
+
return values;
|
|
14414
|
+
}
|
|
14415
|
+
|
|
14416
|
+
/** Computes `mode` (`'sum'|'count'|'avg'|'median'|'min'|'max'|'stddev'`) over `values`. */
|
|
14417
|
+
static aggregateValues(values, mode = 'sum', sumValue = null, countOverride = null) {
|
|
14418
|
+
if (mode === 'count') {
|
|
14419
|
+
if (typeof countOverride === 'number') return countOverride;
|
|
14420
|
+
return Array.isArray(values) ? values.length : 0;
|
|
14421
|
+
}
|
|
14422
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
14423
|
+
return mode === 'sum' ? sumValue : null;
|
|
14424
|
+
}
|
|
14425
|
+
const round3 = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null);
|
|
14426
|
+
switch (mode) {
|
|
14427
|
+
case 'sum':
|
|
14428
|
+
return sumValue !== null ? sumValue : round3(values.reduce((a, b) => a + b, 0));
|
|
14429
|
+
case 'avg': {
|
|
14430
|
+
if (typeof countOverride === 'number' && countOverride > 0 && typeof sumValue === 'number') {
|
|
14431
|
+
return round3(sumValue / countOverride);
|
|
14432
|
+
}
|
|
14433
|
+
return round3(values.reduce((a, b) => a + b, 0) / values.length);
|
|
14434
|
+
}
|
|
14435
|
+
case 'median': {
|
|
14436
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
14437
|
+
const mid = Math.floor(sorted.length / 2);
|
|
14438
|
+
const med = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
14439
|
+
return round3(med);
|
|
14440
|
+
}
|
|
14441
|
+
case 'min':
|
|
14442
|
+
return round3(Math.min(...values));
|
|
14443
|
+
case 'max':
|
|
14444
|
+
return round3(Math.max(...values));
|
|
14445
|
+
case 'stddev': {
|
|
14446
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
14447
|
+
const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length;
|
|
14448
|
+
return round3(Math.sqrt(variance));
|
|
14449
|
+
}
|
|
14450
|
+
default:
|
|
14451
|
+
return sumValue;
|
|
14452
|
+
}
|
|
14453
|
+
}
|
|
14454
|
+
|
|
14455
|
+
/** The value at a row and column, from the row the data gave for that pair (or a post-rollup derived formula, or the active `aggregation`); null when it gave none. */
|
|
13785
14456
|
value(rowNode, column) {
|
|
13786
14457
|
const row = this.cell(rowNode, column.node);
|
|
13787
|
-
|
|
14458
|
+
if (row === null) return null;
|
|
14459
|
+
const raw = this._rowMeasureValue(row, column.measure);
|
|
14460
|
+
if (!this.aggregation || this.aggregation === 'sum') return raw;
|
|
14461
|
+
const sample = this._cellSample(rowNode, column.node, column.measure);
|
|
14462
|
+
return PivotView.aggregateValues(sample, this.aggregation, raw, row._count);
|
|
14463
|
+
}
|
|
14464
|
+
|
|
14465
|
+
/**
|
|
14466
|
+
* The value at a row and column in the active `valueMode`: the raw value (`'value'`), or its share
|
|
14467
|
+
* (`0..100`) of the row total (`'rowPercent'`), column total (`'columnPercent'`), or grand total
|
|
14468
|
+
* (`'totalPercent'`). Returns null when the cell or its reference total is missing or zero.
|
|
14469
|
+
*/
|
|
14470
|
+
displayValue(rowNode, column) {
|
|
14471
|
+
const v = this.value(rowNode, column);
|
|
14472
|
+
if (this.valueMode === 'value' || typeof v !== 'number') return v;
|
|
14473
|
+
const base = this.valueMode === 'rowPercent'
|
|
14474
|
+
? this.value(rowNode, { node: this.columnRoot, measure: column.measure })
|
|
14475
|
+
: this.valueMode === 'columnPercent'
|
|
14476
|
+
? this.value(this.rowRoot, column)
|
|
14477
|
+
: this.value(this.rowRoot, { node: this.columnRoot, measure: column.measure });
|
|
14478
|
+
if (typeof base !== 'number' || base === 0) return null;
|
|
14479
|
+
return Math.round((v / base) * 1000) / 10;
|
|
14480
|
+
}
|
|
14481
|
+
|
|
14482
|
+
/** How a cell at `(rowNode, column)` is formatted for display in the grid. */
|
|
14483
|
+
cellText(rowNode, column, nullText = '—') {
|
|
14484
|
+
const row = this.cell(rowNode, column.node);
|
|
14485
|
+
if (row === null) return '';
|
|
14486
|
+
if (this.valueMode === 'value') return this.valueText(column.measure, this.value(rowNode, column), nullText);
|
|
14487
|
+
const pct = this.displayValue(rowNode, column);
|
|
14488
|
+
return pct == null ? nullText : `${pct.toFixed(1)}%`;
|
|
13788
14489
|
}
|
|
13789
14490
|
|
|
13790
14491
|
// ── Changes ───────────────────────────────────────────────────────────────
|
|
@@ -13806,14 +14507,135 @@
|
|
|
13806
14507
|
this._changed();
|
|
13807
14508
|
}
|
|
13808
14509
|
|
|
14510
|
+
/**
|
|
14511
|
+
* Expands an axis to `level` (1 = outermost dimension only, 2 = first two dimensions, etc.) by
|
|
14512
|
+
* expanding collapsible members above `level` and collapsing those at or below `level`.
|
|
14513
|
+
*/
|
|
14514
|
+
expandToLevel(axis, level) {
|
|
14515
|
+
const root = axis === 'rows' ? this.rowRoot : this.columnRoot;
|
|
14516
|
+
this.collapsed[axis].clear();
|
|
14517
|
+
const walk = (node) => {
|
|
14518
|
+
for (const c of node.children.values()) {
|
|
14519
|
+
if (c.path.length >= level && this.collapsible(c)) this.collapsed[axis].add(c.key);
|
|
14520
|
+
walk(c);
|
|
14521
|
+
}
|
|
14522
|
+
};
|
|
14523
|
+
walk(root);
|
|
14524
|
+
this._changed();
|
|
14525
|
+
}
|
|
14526
|
+
|
|
14527
|
+
/** Sets how cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
14528
|
+
setValueMode(mode) {
|
|
14529
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(mode) ? mode : 'value';
|
|
14530
|
+
this._changed();
|
|
14531
|
+
}
|
|
14532
|
+
|
|
14533
|
+
/**
|
|
14534
|
+
* Sets the summary / aggregation function for cube cells:
|
|
14535
|
+
* `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`.
|
|
14536
|
+
*/
|
|
14537
|
+
setAggregation(mode) {
|
|
14538
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === mode) ? mode : 'sum';
|
|
14539
|
+
this._changed();
|
|
14540
|
+
}
|
|
14541
|
+
|
|
13809
14542
|
/** Rows become columns and columns rows. */
|
|
13810
14543
|
swapAxes() {
|
|
13811
14544
|
[this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
|
|
13812
14545
|
this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
|
|
14546
|
+
this.rowSort = null;
|
|
14547
|
+
this.topN = null;
|
|
13813
14548
|
this._index();
|
|
13814
14549
|
this._changed();
|
|
13815
14550
|
}
|
|
13816
14551
|
|
|
14552
|
+
/**
|
|
14553
|
+
* Sorts row members within each level by `column` (`{ node?, columnKey?, measure }`) in `direction`
|
|
14554
|
+
* ('asc', 'desc', or null to clear).
|
|
14555
|
+
*/
|
|
14556
|
+
sortRowsBy(column, direction = 'desc') {
|
|
14557
|
+
if (!direction || !column) {
|
|
14558
|
+
this.rowSort = null;
|
|
14559
|
+
} else {
|
|
14560
|
+
const columnKey = column.columnKey ?? column.node?.key ?? this.columnRoot.key;
|
|
14561
|
+
const measure = column.measure ?? this.measures[0];
|
|
14562
|
+
this.rowSort = { columnKey, measure, direction };
|
|
14563
|
+
}
|
|
14564
|
+
this._changed();
|
|
14565
|
+
}
|
|
14566
|
+
|
|
14567
|
+
/** Clears any row sort, returning row members to their data order. */
|
|
14568
|
+
clearRowSort() {
|
|
14569
|
+
this.rowSort = null;
|
|
14570
|
+
this._changed();
|
|
14571
|
+
}
|
|
14572
|
+
|
|
14573
|
+
/**
|
|
14574
|
+
* Filters sibling row members at each level to the top (`direction = 'desc'`) or bottom
|
|
14575
|
+
* (`direction = 'asc'`) `count` members by `measure` / `column`. Pass `count <= 0` or `null` to clear.
|
|
14576
|
+
*/
|
|
14577
|
+
setTopN(count, { column = null, columnKey = null, measure = null, direction = 'desc' } = {}) {
|
|
14578
|
+
if (!count || count <= 0) {
|
|
14579
|
+
this.topN = null;
|
|
14580
|
+
} else {
|
|
14581
|
+
this.topN = {
|
|
14582
|
+
count: Math.floor(count),
|
|
14583
|
+
columnKey: columnKey ?? column?.columnKey ?? column?.node?.key ?? this.columnRoot.key,
|
|
14584
|
+
measure: measure ?? column?.measure ?? this.measures[0],
|
|
14585
|
+
direction: direction === 'asc' ? 'asc' : 'desc',
|
|
14586
|
+
};
|
|
14587
|
+
}
|
|
14588
|
+
this._changed();
|
|
14589
|
+
}
|
|
14590
|
+
|
|
14591
|
+
/** Clears any Top-N / Bottom-N row filter, showing all row members again. */
|
|
14592
|
+
clearTopN() {
|
|
14593
|
+
this.topN = null;
|
|
14594
|
+
this._changed();
|
|
14595
|
+
}
|
|
14596
|
+
|
|
14597
|
+
/**
|
|
14598
|
+
* Adds or updates a post-rollup derived formula measure (`{ name, formula, params? }`), evaluated at
|
|
14599
|
+
* every detail, subtotal, and grand-total cell via `compileFormula`.
|
|
14600
|
+
*/
|
|
14601
|
+
addDerivedMeasure({ name, formula, params = {} }) {
|
|
14602
|
+
if (!name || !formula) return;
|
|
14603
|
+
this.derivedMeasures.set(name, PivotView._compileDerived({ name, formula, params }));
|
|
14604
|
+
if (!this.allMeasures.includes(name)) this.allMeasures.push(name);
|
|
14605
|
+
if (!this.measures.includes(name)) this.measures.push(name);
|
|
14606
|
+
this._changed();
|
|
14607
|
+
}
|
|
14608
|
+
|
|
14609
|
+
/** Removes a derived formula measure by name. */
|
|
14610
|
+
removeDerivedMeasure(name) {
|
|
14611
|
+
if (!this.derivedMeasures.has(name)) return;
|
|
14612
|
+
this.derivedMeasures.delete(name);
|
|
14613
|
+
this.allMeasures = this.allMeasures.filter(m => m !== name);
|
|
14614
|
+
const next = this.measures.filter(m => m !== name);
|
|
14615
|
+
this.measures = next.length > 0 ? next : [...this.allMeasures];
|
|
14616
|
+
this._changed();
|
|
14617
|
+
}
|
|
14618
|
+
|
|
14619
|
+
/** Shows or hides a measure (`allMeasures` holds every available measure; at least one stays visible). */
|
|
14620
|
+
toggleMeasure(measure) {
|
|
14621
|
+
if (!this.allMeasures.includes(measure)) return;
|
|
14622
|
+
if (this.measures.includes(measure)) {
|
|
14623
|
+
if (this.measures.length <= 1) return;
|
|
14624
|
+
this.measures = this.measures.filter(m => m !== measure);
|
|
14625
|
+
} else {
|
|
14626
|
+
this.measures = this.allMeasures.filter(m => this.measures.includes(m) || m === measure);
|
|
14627
|
+
}
|
|
14628
|
+
this._changed();
|
|
14629
|
+
}
|
|
14630
|
+
|
|
14631
|
+
/** Sets the visible measures (kept in `allMeasures` order; ignored if none match). */
|
|
14632
|
+
setMeasures(measures) {
|
|
14633
|
+
const next = this.allMeasures.filter(m => measures.includes(m));
|
|
14634
|
+
if (next.length === 0) return;
|
|
14635
|
+
this.measures = next;
|
|
14636
|
+
this._changed();
|
|
14637
|
+
}
|
|
14638
|
+
|
|
13817
14639
|
_changed() {
|
|
13818
14640
|
this.onChange?.(this);
|
|
13819
14641
|
for (const listener of this._listeners) listener(this);
|
|
@@ -13996,22 +14818,83 @@
|
|
|
13996
14818
|
const names = columns.map(seriesName);
|
|
13997
14819
|
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
13998
14820
|
[category, rowLabels[r]],
|
|
13999
|
-
...columns.map((c, i) => [names[i], this.
|
|
14821
|
+
...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
|
|
14000
14822
|
]));
|
|
14001
14823
|
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
14002
14824
|
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
14003
14825
|
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
14004
14826
|
const measureOf = (c) => hint(c.measure).measure ?? null;
|
|
14827
|
+
const primaryMeasure = this.measures[0] ?? null;
|
|
14828
|
+
const totalCol = this.columnRoot.hasCells && primaryMeasure ? { node: this.columnRoot, measure: primaryMeasure } : null;
|
|
14829
|
+
const rowTotals = totalCol
|
|
14830
|
+
? rowNodes.map((node, r) => ({ label: rowLabels[r], value: this.displayValue(node, totalCol) })).filter(d => d.value != null)
|
|
14831
|
+
: null;
|
|
14005
14832
|
return {
|
|
14006
14833
|
rows,
|
|
14007
14834
|
columns: [
|
|
14008
14835
|
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
14009
14836
|
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
14010
14837
|
],
|
|
14011
|
-
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
14012
|
-
complete: this.unplaced === 0,
|
|
14838
|
+
// Members not broken down further cover the whole answer between them, unless rows went unplaced or Top-N filtered.
|
|
14839
|
+
complete: this.unplaced === 0 && !this.topN,
|
|
14013
14840
|
members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
|
|
14841
|
+
columnMembers: new Map(columns.map((c, i) => [names[i], c.node])),
|
|
14842
|
+
rowTotals,
|
|
14843
|
+
primaryMeasure: primaryMeasure ? { name: primaryMeasure, measure: measureOf({ measure: primaryMeasure }) } : null,
|
|
14844
|
+
hierarchy: this.hierarchyData(),
|
|
14845
|
+
};
|
|
14846
|
+
}
|
|
14847
|
+
|
|
14848
|
+
/**
|
|
14849
|
+
* The row-axis member tree shaped for hierarchical charts (`treemap`, `sunburst`, `outline`, `icicle`):
|
|
14850
|
+
* `Array<{ label, value?, children? }>`, using the single column/measure showing (or the row totals
|
|
14851
|
+
* column when multiple column members are showing) when the measure is additive and all values are
|
|
14852
|
+
* non-negative, or null when hierarchical part-of-whole does not fit.
|
|
14853
|
+
*/
|
|
14854
|
+
hierarchyData() {
|
|
14855
|
+
if (this.unplaced > 0 || this.topN || this.rowAxis.length < 1) return null;
|
|
14856
|
+
const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
|
|
14857
|
+
const col = columns.length === 1
|
|
14858
|
+
? columns[0]
|
|
14859
|
+
: (columns.length > 1 && this.columnRoot.hasCells && this.measures.length > 0
|
|
14860
|
+
? { node: this.columnRoot, measure: this.measures[0] }
|
|
14861
|
+
: null);
|
|
14862
|
+
if (!col) return null;
|
|
14863
|
+
const hint = (this._options.columns ?? []).find(c => c.name === col.measure) ?? {};
|
|
14864
|
+
if (hint.measure !== 'additive') return null;
|
|
14865
|
+
const allNodes = [];
|
|
14866
|
+
const collect = (node) => {
|
|
14867
|
+
for (const child of node.children.values()) {
|
|
14868
|
+
allNodes.push(child);
|
|
14869
|
+
if (!this.isCollapsed('rows', child)) collect(child);
|
|
14870
|
+
}
|
|
14871
|
+
};
|
|
14872
|
+
collect(this.rowRoot);
|
|
14873
|
+
if (allNodes.length === 0) return null;
|
|
14874
|
+
const labelMap = new Map(allNodes.map((n, i) => [n, PivotView._labels(allNodes)[i]]));
|
|
14875
|
+
let hasDeep = false;
|
|
14876
|
+
let negative = false;
|
|
14877
|
+
const build = (parent) => {
|
|
14878
|
+
const list = [];
|
|
14879
|
+
for (const child of parent.children.values()) {
|
|
14880
|
+
const label = labelMap.get(child) ?? PivotView.label(child);
|
|
14881
|
+
if (child.children.size > 0 && !this.isCollapsed('rows', child)) {
|
|
14882
|
+
const children = build(child);
|
|
14883
|
+
if (children.length > 0) {
|
|
14884
|
+
hasDeep = true;
|
|
14885
|
+
list.push({ label, children });
|
|
14886
|
+
}
|
|
14887
|
+
} else {
|
|
14888
|
+
const v = this.value(child, col);
|
|
14889
|
+
if (typeof v === 'number' && v < 0) negative = true;
|
|
14890
|
+
if (typeof v === 'number' && v > 0) list.push({ label, value: v });
|
|
14891
|
+
}
|
|
14892
|
+
}
|
|
14893
|
+
return list;
|
|
14014
14894
|
};
|
|
14895
|
+
const tree = build(this.rowRoot);
|
|
14896
|
+
if (negative || tree.length === 0 || (!hasDeep && this.rowAxis.length < 2)) return null;
|
|
14897
|
+
return { tree, members: new Map(allNodes.map(n => [labelMap.get(n) ?? PivotView.label(n), n])), measure: col.measure };
|
|
14015
14898
|
}
|
|
14016
14899
|
|
|
14017
14900
|
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
@@ -14024,19 +14907,33 @@
|
|
|
14024
14907
|
}
|
|
14025
14908
|
|
|
14026
14909
|
/**
|
|
14027
|
-
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the
|
|
14028
|
-
* finest level drills down (the host's, through onDrill).
|
|
14910
|
+
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the axis's
|
|
14911
|
+
* finest level drills down (the host's, through onDrill). `axis` defaults to `'rows'`, or if a
|
|
14912
|
+
* row label does not match and `series` is given, tries `'columns'`. Says which it did, or null.
|
|
14029
14913
|
*/
|
|
14030
|
-
drillFrom(label) {
|
|
14031
|
-
const
|
|
14032
|
-
|
|
14033
|
-
if (
|
|
14034
|
-
this.
|
|
14035
|
-
|
|
14914
|
+
drillFrom(label, series = null) {
|
|
14915
|
+
const data = this.chartData();
|
|
14916
|
+
const rowNode = data.members.get(label) ?? data.hierarchy?.members.get(label);
|
|
14917
|
+
if (rowNode && rowNode.path.length > 0) {
|
|
14918
|
+
if (this.isCollapsed('rows', rowNode)) {
|
|
14919
|
+
this.toggle('rows', rowNode);
|
|
14920
|
+
return 'expanded';
|
|
14921
|
+
}
|
|
14922
|
+
if (rowNode.path.length === this.rowAxis.length && this.onDrill) {
|
|
14923
|
+
this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: rowNode.path });
|
|
14924
|
+
return 'drilled';
|
|
14925
|
+
}
|
|
14036
14926
|
}
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
|
|
14927
|
+
const colNode = series ? data.columnMembers.get(series) : data.columnMembers.get(label);
|
|
14928
|
+
if (colNode && colNode.path.length > 0) {
|
|
14929
|
+
if (this.isCollapsed('columns', colNode)) {
|
|
14930
|
+
this.toggle('columns', colNode);
|
|
14931
|
+
return 'expanded';
|
|
14932
|
+
}
|
|
14933
|
+
if (colNode.path.length === this.columnAxis.length && this.onDrill) {
|
|
14934
|
+
this.onDrill({ axis: 'columns', dimension: this.columnAxis.at(-1), direction: 'down', member: colNode.path });
|
|
14935
|
+
return 'drilled';
|
|
14936
|
+
}
|
|
14040
14937
|
}
|
|
14041
14938
|
return null;
|
|
14042
14939
|
}
|
|
@@ -14068,12 +14965,26 @@
|
|
|
14068
14965
|
}
|
|
14069
14966
|
}
|
|
14070
14967
|
items.push(...this._memberFilterItems(dimension, node));
|
|
14968
|
+
if (axis === 'columns') items.push(...this._columnSortItems(node));
|
|
14071
14969
|
items.push({ separator: true },
|
|
14072
14970
|
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
14073
14971
|
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
14074
14972
|
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
14075
14973
|
}
|
|
14076
14974
|
|
|
14975
|
+
_columnSortItems(node) {
|
|
14976
|
+
if (this.rowAxis.length === 0 || !node.hasCells) return [];
|
|
14977
|
+
const measure = this.measures[0];
|
|
14978
|
+
const active = this.rowSort?.columnKey === node.key && this.rowSort?.measure === measure ? this.rowSort.direction : null;
|
|
14979
|
+
const label = PivotView.label(node);
|
|
14980
|
+
return [
|
|
14981
|
+
{ separator: true },
|
|
14982
|
+
{ label: `Sort rows by ${label} descending${active === 'desc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'desc') },
|
|
14983
|
+
{ label: `Sort rows by ${label} ascending${active === 'asc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'asc') },
|
|
14984
|
+
...(this.rowSort ? [{ label: 'Clear row sort', action: () => this.clearRowSort() }] : []),
|
|
14985
|
+
];
|
|
14986
|
+
}
|
|
14987
|
+
|
|
14077
14988
|
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
14078
14989
|
|
|
14079
14990
|
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
@@ -14162,7 +15073,13 @@
|
|
|
14162
15073
|
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
14163
15074
|
*/
|
|
14164
15075
|
displayState() {
|
|
14165
|
-
|
|
15076
|
+
const state = { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
|
|
15077
|
+
if (this.rowSort) state.rowSort = { ...this.rowSort };
|
|
15078
|
+
if (this.topN) state.topN = { ...this.topN };
|
|
15079
|
+
if (this.allMeasures.length > 1 && this.measures.length < this.allMeasures.length) state.measures = [...this.measures];
|
|
15080
|
+
if (this.valueMode !== 'value') state.valueMode = this.valueMode;
|
|
15081
|
+
if (this.aggregation !== 'sum') state.aggregation = this.aggregation;
|
|
15082
|
+
return state;
|
|
14166
15083
|
}
|
|
14167
15084
|
|
|
14168
15085
|
/**
|
|
@@ -14173,6 +15090,16 @@
|
|
|
14173
15090
|
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
14174
15091
|
const lights = state.lights ?? null;
|
|
14175
15092
|
this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
|
|
15093
|
+
this.rowSort = state.rowSort && ['asc', 'desc'].includes(state.rowSort.direction) ? { ...state.rowSort } : null;
|
|
15094
|
+
this.topN = state.topN && state.topN.count > 0 && ['asc', 'desc'].includes(state.topN.direction) ? { ...state.topN } : null;
|
|
15095
|
+
if (Array.isArray(state.measures)) {
|
|
15096
|
+
const kept = this.allMeasures.filter(m => state.measures.includes(m));
|
|
15097
|
+
if (kept.length > 0) this.measures = kept;
|
|
15098
|
+
} else {
|
|
15099
|
+
this.measures = [...this.allMeasures];
|
|
15100
|
+
}
|
|
15101
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(state.valueMode) ? state.valueMode : 'value';
|
|
15102
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === state.aggregation) ? state.aggregation : 'sum';
|
|
14176
15103
|
this._changed();
|
|
14177
15104
|
}
|
|
14178
15105
|
|
|
@@ -14222,12 +15149,81 @@
|
|
|
14222
15149
|
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
14223
15150
|
}
|
|
14224
15151
|
|
|
14225
|
-
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
|
|
15152
|
+
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, measures, sort, copy the grid. */
|
|
14226
15153
|
gridMenu() {
|
|
14227
15154
|
const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
|
|
15155
|
+
const measureItems = this.allMeasures.length > 1 ? [{
|
|
15156
|
+
label: 'Measures',
|
|
15157
|
+
items: this.allMeasures.map(m => ({
|
|
15158
|
+
label: `${m}${this.measures.includes(m) ? ' ✓' : ''}`,
|
|
15159
|
+
disabled: this.measures.length === 1 && this.measures[0] === m,
|
|
15160
|
+
action: () => this.toggleMeasure(m),
|
|
15161
|
+
})),
|
|
15162
|
+
}] : [];
|
|
15163
|
+
const sortItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
15164
|
+
label: 'Sort rows',
|
|
15165
|
+
items: [
|
|
15166
|
+
...this.measures.flatMap(m => [
|
|
15167
|
+
{
|
|
15168
|
+
label: `By ${this.measures.length > 1 ? m + ' total' : 'total'} — highest first${this.rowSort?.columnKey === this.columnRoot.key && this.rowSort?.measure === m && this.rowSort?.direction === 'desc' ? ' ✓' : ''}`,
|
|
15169
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'desc'),
|
|
15170
|
+
},
|
|
15171
|
+
{
|
|
15172
|
+
label: `By ${this.measures.length > 1 ? m + ' total' : 'total'} — lowest first${this.rowSort?.columnKey === this.columnRoot.key && this.rowSort?.measure === m && this.rowSort?.direction === 'asc' ? ' ✓' : ''}`,
|
|
15173
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'asc'),
|
|
15174
|
+
},
|
|
15175
|
+
]),
|
|
15176
|
+
...(this.rowSort ? [{ separator: true }, { label: 'Default order', action: () => this.clearRowSort() }] : []),
|
|
15177
|
+
],
|
|
15178
|
+
}] : [];
|
|
15179
|
+
const topNChoice = (count, direction, label) => {
|
|
15180
|
+
const m = this.measures[0];
|
|
15181
|
+
const active = this.topN?.count === count && this.topN?.direction === direction && this.topN?.measure === m;
|
|
15182
|
+
return {
|
|
15183
|
+
label: `${label}${active ? ' ✓' : ''}`,
|
|
15184
|
+
action: () => this.setTopN(count, { node: this.columnRoot, measure: m, direction }),
|
|
15185
|
+
};
|
|
15186
|
+
};
|
|
15187
|
+
const topNItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
15188
|
+
label: 'Top / Bottom N',
|
|
15189
|
+
items: [
|
|
15190
|
+
topNChoice(3, 'desc', 'Top 3'),
|
|
15191
|
+
topNChoice(5, 'desc', 'Top 5'),
|
|
15192
|
+
topNChoice(10, 'desc', 'Top 10'),
|
|
15193
|
+
{ separator: true },
|
|
15194
|
+
topNChoice(3, 'asc', 'Bottom 3'),
|
|
15195
|
+
topNChoice(5, 'asc', 'Bottom 5'),
|
|
15196
|
+
...(this.topN ? [{ separator: true }, { label: 'Show all rows', action: () => this.clearTopN() }] : []),
|
|
15197
|
+
],
|
|
15198
|
+
}] : [];
|
|
15199
|
+
const aggregationItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
15200
|
+
label: 'Summary (Count / Avg / Median)',
|
|
15201
|
+
items: PivotView.AGGREGATIONS.map(a => ({
|
|
15202
|
+
label: `${a.label}${this.aggregation === a.key ? ' ✓' : ''}`,
|
|
15203
|
+
action: () => this.setAggregation(a.key),
|
|
15204
|
+
})),
|
|
15205
|
+
}] : [];
|
|
15206
|
+
const valueModeChoice = (label, mode) => ({
|
|
15207
|
+
label: `${label}${this.valueMode === mode ? ' ✓' : ''}`,
|
|
15208
|
+
action: () => this.setValueMode(mode),
|
|
15209
|
+
});
|
|
15210
|
+
const valueModeItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
15211
|
+
label: 'Show values as',
|
|
15212
|
+
items: [
|
|
15213
|
+
valueModeChoice('Values', 'value'),
|
|
15214
|
+
...(this.columnAxis.length > 0 && this.columnRoot.hasCells ? [valueModeChoice('% of row total', 'rowPercent')] : []),
|
|
15215
|
+
...(this.rowAxis.length > 0 && this.rowRoot.hasCells ? [valueModeChoice('% of column total', 'columnPercent')] : []),
|
|
15216
|
+
...(this.rowRoot.hasCells && this.columnRoot.hasCells ? [valueModeChoice('% of grand total', 'totalPercent')] : []),
|
|
15217
|
+
],
|
|
15218
|
+
}] : [];
|
|
14228
15219
|
return [
|
|
14229
15220
|
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
14230
15221
|
{ label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
|
|
15222
|
+
...sortItems,
|
|
15223
|
+
...topNItems,
|
|
15224
|
+
...measureItems,
|
|
15225
|
+
...aggregationItems,
|
|
15226
|
+
...valueModeItems,
|
|
14231
15227
|
{
|
|
14232
15228
|
label: 'Traffic lights',
|
|
14233
15229
|
items: [
|
|
@@ -14369,7 +15365,7 @@
|
|
|
14369
15365
|
const lit = view.cellFill(node, c, cuts);
|
|
14370
15366
|
if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
|
|
14371
15367
|
const row = view.cell(node, c.node);
|
|
14372
|
-
const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
|
|
15368
|
+
const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
|
|
14373
15369
|
const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
|
|
14374
15370
|
// A cell with a value opens its menu (drill-through) where the host gives one.
|
|
14375
15371
|
if (row !== null && view.cellHasMenu()) {
|
|
@@ -14472,8 +15468,182 @@
|
|
|
14472
15468
|
|
|
14473
15469
|
/** The charts that fit what the grid shows, best first. The grid itself is the table. */
|
|
14474
15470
|
choices() {
|
|
14475
|
-
const
|
|
14476
|
-
|
|
15471
|
+
const chartData = this.view.chartData();
|
|
15472
|
+
const { rows, columns, complete } = chartData;
|
|
15473
|
+
const base = this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
|
|
15474
|
+
const seen = new Set(base.map(c => c.key));
|
|
15475
|
+
const extra = this._cubeChoices(chartData).filter(c => !seen.has(c.key) && this.chooser.registry.get(c.key));
|
|
15476
|
+
return [...base, ...extra];
|
|
15477
|
+
}
|
|
15478
|
+
|
|
15479
|
+
/**
|
|
15480
|
+
* Additional chart types suited to a cube's hierarchy, series, and matrix structure beyond the
|
|
15481
|
+
* general flat-table defaults in `ChartChooser`.
|
|
15482
|
+
*/
|
|
15483
|
+
_cubeChoices({ rows, columns, complete, hierarchy, rowTotals = null, primaryMeasure = null }) {
|
|
15484
|
+
if (!rows || rows.length === 0) return [];
|
|
15485
|
+
const categories = columns.filter(c => c.kind !== 'numeric');
|
|
15486
|
+
const numbers = columns.filter(c => c.kind === 'numeric');
|
|
15487
|
+
if (categories.length !== 1 || numbers.length === 0) return [];
|
|
15488
|
+
const category = categories[0];
|
|
15489
|
+
const add = (key, reason, data, options) => ({
|
|
15490
|
+
key,
|
|
15491
|
+
label: this.chooser.registry.get(key)?.label ?? key,
|
|
15492
|
+
reason,
|
|
15493
|
+
data,
|
|
15494
|
+
...(options ? { options } : {}),
|
|
15495
|
+
});
|
|
15496
|
+
const out = [];
|
|
15497
|
+
|
|
15498
|
+
if (numbers.length === 1) {
|
|
15499
|
+
const number = numbers[0];
|
|
15500
|
+
const points = rows.filter(r => r[number.name] != null).map(r => ({
|
|
15501
|
+
label: String(r[category.name]),
|
|
15502
|
+
value: r[number.name],
|
|
15503
|
+
}));
|
|
15504
|
+
if (points.length < 2) return out;
|
|
15505
|
+
this._addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, false);
|
|
15506
|
+
return out;
|
|
15507
|
+
}
|
|
15508
|
+
|
|
15509
|
+
// Multi-series (2+ numeric series from column-axis members or multiple measures).
|
|
15510
|
+
const gaps = rows.some(r => numbers.some(n => r[n.name] == null));
|
|
15511
|
+
if (gaps) return out;
|
|
15512
|
+
|
|
15513
|
+
const names = numbers.map(n => n.name).join(', ');
|
|
15514
|
+
const seriesData = numbers.map(n => ({
|
|
15515
|
+
name: n.name,
|
|
15516
|
+
values: rows.map(r => ({ label: String(r[category.name]), value: r[n.name] })),
|
|
15517
|
+
}));
|
|
15518
|
+
const allNonNeg = rows.every(r => numbers.every(n => r[n.name] >= 0));
|
|
15519
|
+
const additiveNonNeg = allNonNeg && numbers.every(n => n.measure !== 'semi-additive' && n.measure !== 'non-additive');
|
|
15520
|
+
|
|
15521
|
+
if (additiveNonNeg) {
|
|
15522
|
+
out.push(add('stackedBar', `${names} stacked for each ${category.name}`, seriesData));
|
|
15523
|
+
out.push(add('marimekko', `${names} share and total across ${category.name}, as a Marimekko mosaic`, seriesData));
|
|
15524
|
+
}
|
|
15525
|
+
out.push(add('lollipop', `${names} for each ${category.name}, as stems and dots`, seriesData));
|
|
15526
|
+
if (allNonNeg && rows.length >= 2) {
|
|
15527
|
+
out.push(add('area', `${names} across ${category.name}, filled`, seriesData));
|
|
15528
|
+
}
|
|
15529
|
+
if (additiveNonNeg && rows.length >= 2) {
|
|
15530
|
+
out.push(add('stackedArea', `${names} stacked across ${category.name}`, seriesData));
|
|
15531
|
+
out.push(add('streamGraph', `${names} flowing across ${category.name}`, seriesData));
|
|
15532
|
+
}
|
|
15533
|
+
if (allNonNeg && rows.length >= 2) {
|
|
15534
|
+
out.push(add('pipe', `${names} tapering across ${category.name}`, seriesData.map(s => ({
|
|
15535
|
+
name: s.name,
|
|
15536
|
+
values: s.values.map(v => ({ label: v.label, value: v.value, metric: v.value })),
|
|
15537
|
+
}))));
|
|
15538
|
+
}
|
|
15539
|
+
const heatData = rows.flatMap(r => numbers.map(n => ({
|
|
15540
|
+
x: String(r[category.name]),
|
|
15541
|
+
y: n.name,
|
|
15542
|
+
value: r[n.name],
|
|
15543
|
+
})));
|
|
15544
|
+
out.push(add('heatmap', `${names} for each ${category.name}, as a colour grid`, heatData));
|
|
15545
|
+
if (allNonNeg && rows.length >= 3 && rows.length <= 12) {
|
|
15546
|
+
out.push(add('radar', `${names} across ${category.name} axes`, seriesData.map(s => ({
|
|
15547
|
+
name: s.name,
|
|
15548
|
+
values: s.values.map(v => ({ axis: v.label, value: v.value })),
|
|
15549
|
+
}))));
|
|
15550
|
+
}
|
|
15551
|
+
if (rows.length >= 2) {
|
|
15552
|
+
out.push(add('parallelCoordinates', `${names} across parallel axes for each ${category.name}`,
|
|
15553
|
+
rows.map(r => ({
|
|
15554
|
+
label: String(r[category.name]),
|
|
15555
|
+
category: String(r[category.name]),
|
|
15556
|
+
...Object.fromEntries(numbers.map(n => [n.name, r[n.name]])),
|
|
15557
|
+
})),
|
|
15558
|
+
{ dimensions: numbers.map(n => n.name) }));
|
|
15559
|
+
if (allNonNeg) {
|
|
15560
|
+
out.push(add('populationPyramid', `${numbers[0].name} vs ${numbers[1].name} for each ${category.name}`,
|
|
15561
|
+
rows.map(r => ({ label: String(r[category.name]), left: r[numbers[0].name], right: r[numbers[1].name] })),
|
|
15562
|
+
{ leftLabel: numbers[0].name, rightLabel: numbers[1].name, showValues: true }));
|
|
15563
|
+
const flowLinks = rows.flatMap(r => numbers
|
|
15564
|
+
.filter(n => r[n.name] > 0)
|
|
15565
|
+
.map(n => ({ source: String(r[category.name]), target: n.name, value: r[n.name] })));
|
|
15566
|
+
if (flowLinks.length > 0) {
|
|
15567
|
+
out.push(add('sankey', `flow from ${category.name} to ${names}`, { links: flowLinks }));
|
|
15568
|
+
out.push(add('chord', `chords between ${category.name} and ${names}`, { links: flowLinks }));
|
|
15569
|
+
}
|
|
15570
|
+
}
|
|
15571
|
+
if (rows.length >= 3) {
|
|
15572
|
+
const distData = numbers.map(n => ({ label: n.name, values: rows.map(r => r[n.name]) }));
|
|
15573
|
+
out.push(add('boxPlot', `distribution of ${category.name} values for each of ${names}`, distData));
|
|
15574
|
+
out.push(add('density', `density curve of ${category.name} values for each of ${names}`, distData));
|
|
15575
|
+
out.push(add('violin', `violin distribution of ${category.name} values for each of ${names}`, distData));
|
|
15576
|
+
}
|
|
15577
|
+
out.push(add('scatter', `${numbers[0].name} against ${numbers[1].name} for each ${category.name}`,
|
|
15578
|
+
rows.map(r => ({ x: r[numbers[0].name], y: r[numbers[1].name], label: String(r[category.name]) }))));
|
|
15579
|
+
if (numbers.length >= 3 || (rowTotals && rowTotals.length === rows.length)) {
|
|
15580
|
+
out.push(add('bubble', `${numbers[0].name} and ${numbers[1].name} sized by ${numbers[2]?.name ?? (primaryMeasure?.name || 'Total')}`,
|
|
15581
|
+
rows.map((r, idx) => ({
|
|
15582
|
+
x: r[numbers[0].name],
|
|
15583
|
+
y: r[numbers[1].name],
|
|
15584
|
+
z: Math.max(1, numbers[2] ? r[numbers[2].name] : rowTotals[idx].value),
|
|
15585
|
+
label: String(r[category.name]),
|
|
15586
|
+
}))));
|
|
15587
|
+
}
|
|
15588
|
+
}
|
|
15589
|
+
if (allNonNeg) {
|
|
15590
|
+
out.push(add('bar3DOrbit', `${names} for each ${category.name}, in 3D`,
|
|
15591
|
+
rows.flatMap(r => numbers.map(n => ({ x: String(r[category.name]), z: n.name, value: r[n.name] })))));
|
|
15592
|
+
}
|
|
15593
|
+
if (additiveNonNeg) {
|
|
15594
|
+
out.push(add('stackedBar3DOrbit', `${names} stacked for each ${category.name}, in 3D`,
|
|
15595
|
+
rows.map(r => ({ x: String(r[category.name]), z: category.name, values: numbers.map(n => r[n.name]) })),
|
|
15596
|
+
{ seriesLabels: numbers.map(n => n.name) }));
|
|
15597
|
+
}
|
|
15598
|
+
if (rowTotals && rowTotals.length >= 2 && primaryMeasure) {
|
|
15599
|
+
this._addSingleSeriesChoices(out, add, category, primaryMeasure, rowTotals, complete, hierarchy, true);
|
|
15600
|
+
}
|
|
15601
|
+
return out;
|
|
15602
|
+
}
|
|
15603
|
+
|
|
15604
|
+
_addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, includeBase = false) {
|
|
15605
|
+
const nonNeg = points.every(d => d.value >= 0);
|
|
15606
|
+
if (includeBase) {
|
|
15607
|
+
out.push(add('bar', `total ${number.name} for each ${category.name}`, points));
|
|
15608
|
+
if (nonNeg && complete && number.measure === 'additive' && points.length <= 8) {
|
|
15609
|
+
out.push(add('pie', `share of total ${number.name} across ${category.name}`, points));
|
|
15610
|
+
out.push(add('donut', `share of total ${number.name} across ${category.name}, as a ring`, points));
|
|
15611
|
+
}
|
|
15612
|
+
}
|
|
15613
|
+
if (hierarchy) {
|
|
15614
|
+
out.push(add('treemap', `${number.name} across the ${category.name} hierarchy`, hierarchy.tree));
|
|
15615
|
+
out.push(add('sunburst', `${number.name} across the ${category.name} hierarchy, in rings`, hierarchy.tree));
|
|
15616
|
+
out.push(add('icicle', `${number.name} across the ${category.name} hierarchy, as an icicle partition`, hierarchy.tree));
|
|
15617
|
+
out.push(add('outline', `${number.name} across the ${category.name} hierarchy, as an outline`, hierarchy.tree));
|
|
15618
|
+
}
|
|
15619
|
+
if (number.measure === 'additive') {
|
|
15620
|
+
out.push(add('waterfall', `how each ${category.name} adds to ${number.name}`, points));
|
|
15621
|
+
if (nonNeg && complete) {
|
|
15622
|
+
out.push(add('pareto', `cumulative share of ${number.name} across ${category.name}`, points));
|
|
15623
|
+
}
|
|
15624
|
+
}
|
|
15625
|
+
if (!includeBase) {
|
|
15626
|
+
out.push(add('lollipop', `${number.name} for each ${category.name}, as stems and dots`, points));
|
|
15627
|
+
if (nonNeg && points.length >= 2) {
|
|
15628
|
+
out.push(add('pipe', `${number.name} tapering across ${category.name}`,
|
|
15629
|
+
points.map(d => ({ label: d.label, value: d.value, metric: d.value }))));
|
|
15630
|
+
}
|
|
15631
|
+
}
|
|
15632
|
+
if (nonNeg && points.length <= 12) {
|
|
15633
|
+
out.push(add('funnel', `${number.name} across ${category.name} stages`, points));
|
|
15634
|
+
out.push(add('radialBar', `${number.name} for each ${category.name}, around a circle`, points));
|
|
15635
|
+
if (!includeBase && points.length >= 3) {
|
|
15636
|
+
out.push(add('radar', `${number.name} across ${category.name} axes`, points.map(d => ({ axis: d.label, value: d.value }))));
|
|
15637
|
+
}
|
|
15638
|
+
out.push(add('wordCloud', `${category.name} sized by ${number.name}`, points.map(d => ({ text: d.label, value: d.value }))));
|
|
15639
|
+
out.push(add('bar3D', `${number.name} for each ${category.name}, in 3D`, points));
|
|
15640
|
+
}
|
|
15641
|
+
if (points.length >= 3) {
|
|
15642
|
+
const sample = points.map(d => d.value);
|
|
15643
|
+
out.push(add('histogram', `frequency distribution of ${number.name} across ${category.name}`, sample));
|
|
15644
|
+
out.push(add('qq', `normal Q-Q plot of ${number.name} across ${category.name}`, sample));
|
|
15645
|
+
out.push(add('lag', `lag-1 autocorrelation of ${number.name} across ${category.name}`, sample));
|
|
15646
|
+
}
|
|
14477
15647
|
}
|
|
14478
15648
|
|
|
14479
15649
|
/** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
|
|
@@ -14482,20 +15652,826 @@
|
|
|
14482
15652
|
return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
|
|
14483
15653
|
}
|
|
14484
15654
|
|
|
14485
|
-
/**
|
|
14486
|
-
|
|
15655
|
+
/** Sets the preferred chart type key (e.g. `'stackedBar'`, `'line'`, `'bar'`, `'pareto'`) and redraws if attached. */
|
|
15656
|
+
setType(type) {
|
|
15657
|
+
this.type = type;
|
|
15658
|
+
this.redraw?.();
|
|
15659
|
+
return this.chosen();
|
|
15660
|
+
}
|
|
15661
|
+
|
|
15662
|
+
/** The chart's menu: "Chart as" each chart that fits (plus quick top-level items), the one showing ticked. */
|
|
15663
|
+
menu(redraw = () => this.redraw?.()) {
|
|
14487
15664
|
const current = this.chosen()?.key;
|
|
14488
|
-
|
|
14489
|
-
|
|
14490
|
-
|
|
14491
|
-
|
|
14492
|
-
|
|
14493
|
-
|
|
14494
|
-
|
|
15665
|
+
const all = this.choices();
|
|
15666
|
+
const toItem = (c) => ({
|
|
15667
|
+
label: `${c.label}${c.key === current ? ' ✓' : ''}`,
|
|
15668
|
+
action: () => { this.type = c.key; redraw(); },
|
|
15669
|
+
});
|
|
15670
|
+
const quickKeys = new Set([
|
|
15671
|
+
'groupedBar', 'stackedBar', 'bar', 'line', 'area', 'stackedArea',
|
|
15672
|
+
'pareto', 'waterfall', 'marimekko', 'lollipop', 'heatmap', 'radar',
|
|
15673
|
+
'treemap', 'sunburst', 'icicle', 'sankey', 'pie', 'donut',
|
|
15674
|
+
]);
|
|
15675
|
+
const quickItems = all.filter(c => quickKeys.has(c.key)).map(toItem);
|
|
15676
|
+
return [
|
|
15677
|
+
{
|
|
15678
|
+
label: 'Chart as',
|
|
15679
|
+
items: all.map(toItem),
|
|
15680
|
+
},
|
|
15681
|
+
...(quickItems.length > 0 ? [{ separator: true }, ...quickItems] : []),
|
|
15682
|
+
];
|
|
14495
15683
|
}
|
|
14496
15684
|
|
|
14497
|
-
/** A click on a charted value: drill the grid at its member. */
|
|
14498
|
-
click(datum) { return this.view.drillFrom(datum.label); }
|
|
15685
|
+
/** A click on a charted value: drill the grid at its member (or its series' column member). */
|
|
15686
|
+
click(datum) { return this.view.drillFrom(datum.label, datum.series ?? null); }
|
|
15687
|
+
}
|
|
15688
|
+
|
|
15689
|
+
/**
|
|
15690
|
+
* AskView — the state and next-word prediction model for an interactive "Ask" bar
|
|
15691
|
+
* (question input, scrollable predictive typeahead list with semantic sense/reference/relation
|
|
15692
|
+
* badges, keyboard navigation, and configurable next-word prediction), kept apart from DOM
|
|
15693
|
+
* rendering so all of it can be unit-tested under Node.js without a browser.
|
|
15694
|
+
*
|
|
15695
|
+
* A host can supply its own `suggest(text, view)` callback (for example, semanticengine's
|
|
15696
|
+
* `(text) => engine.suggest(text, { sampler: (dim) => engine.members(dim) })`), or pass
|
|
15697
|
+
* declarative vocabulary hints (`measures`, `dimensions`, `members`, `vocabulary`,
|
|
15698
|
+
* `sampleQuestions`) to use `AskView.nextWordPredictor(...)`.
|
|
15699
|
+
*/
|
|
15700
|
+
class AskView {
|
|
15701
|
+
/** Default choices for how many predicted next-word items are visible at once in the scrollable list (`0` = Off). */
|
|
15702
|
+
static DEFAULT_PREDICT_CHOICES = [0, 3, 5, 8, 10];
|
|
15703
|
+
|
|
15704
|
+
/**
|
|
15705
|
+
* @param {object} [options]
|
|
15706
|
+
* @param {string} [options.value=''] initial question text
|
|
15707
|
+
* @param {function} [options.suggest] `(text, view) => Array<string|{display, completion?, sense?, reference?, relation?, kind?}>`, or a promise of one
|
|
15708
|
+
* @param {string[]} [options.measures] measure names for built-in next-word prediction
|
|
15709
|
+
* @param {string[]} [options.dimensions] dimension names for built-in next-word prediction
|
|
15710
|
+
* @param {function|object} [options.members] `(dimension) => Array<string|number>` or `{ dim: [...] }` for value prediction after `=`
|
|
15711
|
+
* @param {Array<string|{display, completion?, sense?, reference?, relation?, kind?}>} [options.vocabulary] custom vocabulary entries
|
|
15712
|
+
* @param {string[]} [options.sampleQuestions] sample questions used both as clickable pills and to learn next-word transitions
|
|
15713
|
+
* @param {boolean} [options.predict=true] whether next-word prediction is initially enabled
|
|
15714
|
+
* @param {number} [options.visibleCount=5] how many items are visible at once in the scrollable suggestion viewport
|
|
15715
|
+
* @param {number[]} [options.predictChoices=[0, 3, 5, 8, 10]] configurable choices for the predict-next-word selector (`0` = Off)
|
|
15716
|
+
* @param {number} [options.maxSuggestions=50] maximum number of suggestions kept in the scrollable list
|
|
15717
|
+
* @param {function} [options.onAsk] `(question, view) => *` called when the user submits the question
|
|
15718
|
+
* @param {function} [options.onComplete] `(item, view) => void` called when a suggestion is accepted
|
|
15719
|
+
* @param {function} [options.onChange] `(view) => void` called whenever view state changes
|
|
15720
|
+
*/
|
|
15721
|
+
constructor(options = {}) {
|
|
15722
|
+
this.value = options.value ?? '';
|
|
15723
|
+
this.samples = [...(options.sampleQuestions ?? options.samples ?? [])];
|
|
15724
|
+
this.predictChoices = Array.isArray(options.predictChoices) && options.predictChoices.length > 0
|
|
15725
|
+
? [...options.predictChoices]
|
|
15726
|
+
: [...AskView.DEFAULT_PREDICT_CHOICES];
|
|
15727
|
+
const initialVisible = Number.isFinite(options.visibleCount) && options.visibleCount > 0 ? Math.floor(options.visibleCount) : 5;
|
|
15728
|
+
this.visibleCount = initialVisible;
|
|
15729
|
+
this.predict = options.predict !== false && options.visibleCount !== 0;
|
|
15730
|
+
if (this.predict && !this.predictChoices.includes(this.visibleCount)) {
|
|
15731
|
+
this.predictChoices = [...new Set([...this.predictChoices, this.visibleCount])].sort((a, b) => a - b);
|
|
15732
|
+
}
|
|
15733
|
+
this.maxSuggestions = Number.isFinite(options.maxSuggestions) && options.maxSuggestions > 0
|
|
15734
|
+
? Math.floor(options.maxSuggestions)
|
|
15735
|
+
: 50;
|
|
15736
|
+
this.suggestFn = typeof options.suggest === 'function'
|
|
15737
|
+
? options.suggest
|
|
15738
|
+
: AskView.nextWordPredictor({
|
|
15739
|
+
measures: options.measures,
|
|
15740
|
+
dimensions: options.dimensions,
|
|
15741
|
+
members: options.members,
|
|
15742
|
+
vocabulary: options.vocabulary,
|
|
15743
|
+
sampleQuestions: this.samples,
|
|
15744
|
+
});
|
|
15745
|
+
this.onAsk = options.onAsk ?? null;
|
|
15746
|
+
this.onComplete = options.onComplete ?? null;
|
|
15747
|
+
this.onChange = options.onChange ?? null;
|
|
15748
|
+
this.items = [];
|
|
15749
|
+
this.selectedIndex = -1;
|
|
15750
|
+
this.open = false;
|
|
15751
|
+
this.history = [];
|
|
15752
|
+
this._ticket = 0;
|
|
15753
|
+
}
|
|
15754
|
+
|
|
15755
|
+
/**
|
|
15756
|
+
* Current predict-next-word setting as a single number (`0` when Off, else `visibleCount`),
|
|
15757
|
+
* matching `predictChoices`.
|
|
15758
|
+
*/
|
|
15759
|
+
get predictCount() {
|
|
15760
|
+
return this.predict ? this.visibleCount : 0;
|
|
15761
|
+
}
|
|
15762
|
+
|
|
15763
|
+
/**
|
|
15764
|
+
* Enables or disables next-word prediction, or sets how many items show at once in the
|
|
15765
|
+
* scrollable list (`0` or `false` turns prediction off; a positive integer enables it with
|
|
15766
|
+
* that viewport size).
|
|
15767
|
+
*/
|
|
15768
|
+
setPredictCount(countOrEnabled) {
|
|
15769
|
+
if (countOrEnabled === false || countOrEnabled === 0 || countOrEnabled === '0' || countOrEnabled === 'off') {
|
|
15770
|
+
this.predict = false;
|
|
15771
|
+
this.items = [];
|
|
15772
|
+
this.selectedIndex = -1;
|
|
15773
|
+
this.open = false;
|
|
15774
|
+
this._changed();
|
|
15775
|
+
return;
|
|
15776
|
+
}
|
|
15777
|
+
if (countOrEnabled === true) {
|
|
15778
|
+
this.predict = true;
|
|
15779
|
+
if (this.visibleCount <= 0) this.visibleCount = 5;
|
|
15780
|
+
} else {
|
|
15781
|
+
const n = Number(countOrEnabled);
|
|
15782
|
+
if (Number.isFinite(n) && n > 0) {
|
|
15783
|
+
this.predict = true;
|
|
15784
|
+
this.visibleCount = Math.floor(n);
|
|
15785
|
+
}
|
|
15786
|
+
}
|
|
15787
|
+
this.refresh();
|
|
15788
|
+
}
|
|
15789
|
+
|
|
15790
|
+
/** Updates the input text and refreshes next-word predictions if enabled. */
|
|
15791
|
+
setInput(text, { suggest = true } = {}) {
|
|
15792
|
+
this.value = String(text ?? '');
|
|
15793
|
+
if (suggest && this.predict) {
|
|
15794
|
+
return this.refresh();
|
|
15795
|
+
} else {
|
|
15796
|
+
if (!suggest) {
|
|
15797
|
+
this.open = false;
|
|
15798
|
+
this.selectedIndex = -1;
|
|
15799
|
+
}
|
|
15800
|
+
this._changed();
|
|
15801
|
+
}
|
|
15802
|
+
}
|
|
15803
|
+
|
|
15804
|
+
/**
|
|
15805
|
+
* Swaps the source of suggestions (a host whose vocabulary changed — another model or
|
|
15806
|
+
* ontology) and refreshes the list.
|
|
15807
|
+
*/
|
|
15808
|
+
setSuggest(suggest) {
|
|
15809
|
+
this.suggestFn = typeof suggest === 'function' ? suggest : null;
|
|
15810
|
+
this.refresh();
|
|
15811
|
+
}
|
|
15812
|
+
|
|
15813
|
+
/**
|
|
15814
|
+
* Recomputes suggestions for the current `value` and opens the list if any match. Nothing is
|
|
15815
|
+
* selected until the user moves into the list, so Enter asks the question as typed.
|
|
15816
|
+
*
|
|
15817
|
+
* `suggest` may answer with a promise (an engine still loading, a server's vocabulary): the
|
|
15818
|
+
* list keeps what it shows until it settles, and an answer for text since changed is dropped.
|
|
15819
|
+
* Returns the items, or a promise of them.
|
|
15820
|
+
*/
|
|
15821
|
+
refresh() {
|
|
15822
|
+
const ticket = ++this._ticket;
|
|
15823
|
+
if (!this.predict || !this.suggestFn) {
|
|
15824
|
+
this._apply([]);
|
|
15825
|
+
return this.items;
|
|
15826
|
+
}
|
|
15827
|
+
let raw;
|
|
15828
|
+
try {
|
|
15829
|
+
raw = this.suggestFn(this.value, this);
|
|
15830
|
+
} catch {
|
|
15831
|
+
this._apply([]);
|
|
15832
|
+
return this.items;
|
|
15833
|
+
}
|
|
15834
|
+
if (raw && typeof raw.then === 'function') {
|
|
15835
|
+
const asked = this.value;
|
|
15836
|
+
return Promise.resolve(raw).then(
|
|
15837
|
+
(items) => (ticket === this._ticket && asked === this.value ? this._apply(items) : this.items),
|
|
15838
|
+
() => (ticket === this._ticket ? this._apply([]) : this.items),
|
|
15839
|
+
);
|
|
15840
|
+
}
|
|
15841
|
+
return this._apply(raw);
|
|
15842
|
+
}
|
|
15843
|
+
|
|
15844
|
+
/** Shows `raw` suggestions (anything not a list shows none). */
|
|
15845
|
+
_apply(raw) {
|
|
15846
|
+
this.items = Array.isArray(raw)
|
|
15847
|
+
? raw.slice(0, this.maxSuggestions).map((item) => AskView.normalizeItem(item, this.value))
|
|
15848
|
+
: [];
|
|
15849
|
+
this.selectedIndex = -1;
|
|
15850
|
+
this.open = this.items.length > 0;
|
|
15851
|
+
this._changed();
|
|
15852
|
+
return this.items;
|
|
15853
|
+
}
|
|
15854
|
+
|
|
15855
|
+
/**
|
|
15856
|
+
* Moves the active highlight up (`-1`) or down (`+1`) in the scrollable list, wrapping around.
|
|
15857
|
+
* From no selection, down goes to the first item and up to the last.
|
|
15858
|
+
*/
|
|
15859
|
+
move(delta) {
|
|
15860
|
+
if (!this.open || this.items.length === 0) {
|
|
15861
|
+
this.refresh();
|
|
15862
|
+
return this.selectedIndex;
|
|
15863
|
+
}
|
|
15864
|
+
const len = this.items.length;
|
|
15865
|
+
this.selectedIndex = this.selectedIndex < 0
|
|
15866
|
+
? (delta > 0 ? 0 : len - 1)
|
|
15867
|
+
: (this.selectedIndex + delta + len) % len;
|
|
15868
|
+
this._changed();
|
|
15869
|
+
return this.selectedIndex;
|
|
15870
|
+
}
|
|
15871
|
+
|
|
15872
|
+
/**
|
|
15873
|
+
* Applies the suggestion at `index` (defaults to `selectedIndex`, or `0`), replacing `value`
|
|
15874
|
+
* with `item.completion` and immediately predicting the next word.
|
|
15875
|
+
*/
|
|
15876
|
+
accept(index = this.selectedIndex >= 0 ? this.selectedIndex : 0) {
|
|
15877
|
+
if (index < 0 || index >= this.items.length) return null;
|
|
15878
|
+
const item = this.items[index];
|
|
15879
|
+
this.value = item.completion;
|
|
15880
|
+
this.onComplete?.(item, this);
|
|
15881
|
+
if (this.predict) {
|
|
15882
|
+
this.refresh();
|
|
15883
|
+
} else {
|
|
15884
|
+
this.close();
|
|
15885
|
+
}
|
|
15886
|
+
return item;
|
|
15887
|
+
}
|
|
15888
|
+
|
|
15889
|
+
/** Closes the suggestion popup without changing `value`. */
|
|
15890
|
+
close() {
|
|
15891
|
+
if (!this.open && this.selectedIndex === -1) return;
|
|
15892
|
+
this.open = false;
|
|
15893
|
+
this.selectedIndex = -1;
|
|
15894
|
+
this._changed();
|
|
15895
|
+
}
|
|
15896
|
+
|
|
15897
|
+
/** Submits the current question (`value`), closes the suggestion list, and invokes `onAsk`. */
|
|
15898
|
+
submit(question = this.value) {
|
|
15899
|
+
this.value = String(question ?? '');
|
|
15900
|
+
this.open = false;
|
|
15901
|
+
this.selectedIndex = -1;
|
|
15902
|
+
const trimmed = this.value.trim();
|
|
15903
|
+
if (trimmed && this.history.at(-1) !== trimmed) {
|
|
15904
|
+
this.history.push(trimmed);
|
|
15905
|
+
}
|
|
15906
|
+
this._changed();
|
|
15907
|
+
return this.onAsk?.(this.value, this);
|
|
15908
|
+
}
|
|
15909
|
+
|
|
15910
|
+
_changed() {
|
|
15911
|
+
this.onChange?.(this);
|
|
15912
|
+
}
|
|
15913
|
+
|
|
15914
|
+
/** Normalizes a string or suggestion object into `{ display, completion, sense, reference, relation, kind }`. */
|
|
15915
|
+
static normalizeItem(item, currentText = '') {
|
|
15916
|
+
if (typeof item === 'string') {
|
|
15917
|
+
return {
|
|
15918
|
+
display: item,
|
|
15919
|
+
completion: AskView._completeWord(currentText, item),
|
|
15920
|
+
sense: null,
|
|
15921
|
+
reference: null,
|
|
15922
|
+
relation: null,
|
|
15923
|
+
kind: 'suggestion',
|
|
15924
|
+
};
|
|
15925
|
+
}
|
|
15926
|
+
const display = String(item.display ?? item.word ?? item.label ?? item.completion ?? '');
|
|
15927
|
+
const completion = item.completion !== undefined
|
|
15928
|
+
? String(item.completion)
|
|
15929
|
+
: AskView._completeWord(currentText, display);
|
|
15930
|
+
return {
|
|
15931
|
+
display,
|
|
15932
|
+
completion,
|
|
15933
|
+
sense: item.sense ?? null,
|
|
15934
|
+
reference: item.reference ?? null,
|
|
15935
|
+
relation: item.relation ?? null,
|
|
15936
|
+
kind: item.kind ?? 'suggestion',
|
|
15937
|
+
};
|
|
15938
|
+
}
|
|
15939
|
+
|
|
15940
|
+
/** Replaces the trailing partial token of `currentText` with `word` (followed by a space). */
|
|
15941
|
+
static _completeWord(currentText, word) {
|
|
15942
|
+
const text = String(currentText ?? '');
|
|
15943
|
+
if (word.includes(' ') && word.toLowerCase().startsWith(text.trim().toLowerCase()) && text.trim().length > 0) {
|
|
15944
|
+
return word;
|
|
15945
|
+
}
|
|
15946
|
+
const match = text.match(/^(.*?)(\S*)$/);
|
|
15947
|
+
const prefix = match ? match[1] : '';
|
|
15948
|
+
const cleanWord = String(word).trim();
|
|
15949
|
+
return `${prefix}${cleanWord} `;
|
|
15950
|
+
}
|
|
15951
|
+
|
|
15952
|
+
/**
|
|
15953
|
+
* Builds a context-aware next-word predictor from declarative cube/table metadata and/or
|
|
15954
|
+
* sample questions. Predicts metrics, dimensions, clauses (`by`, `and`, `where`, `top`, `vs`),
|
|
15955
|
+
* sampled dimension members after `=`, and learned next-word bigrams from `sampleQuestions`.
|
|
15956
|
+
*/
|
|
15957
|
+
static nextWordPredictor({
|
|
15958
|
+
measures = [],
|
|
15959
|
+
dimensions = [],
|
|
15960
|
+
members = null,
|
|
15961
|
+
vocabulary = [],
|
|
15962
|
+
sampleQuestions = [],
|
|
15963
|
+
} = {}) {
|
|
15964
|
+
const catalog = [];
|
|
15965
|
+
const addEntry = (display, kind, sense = null, reference = null, relation = null) => {
|
|
15966
|
+
if (!display) return;
|
|
15967
|
+
if (catalog.some(c => c.display.toLowerCase() === String(display).toLowerCase() && c.kind === kind)) return;
|
|
15968
|
+
catalog.push({ display: String(display), kind, sense, reference, relation });
|
|
15969
|
+
};
|
|
15970
|
+
|
|
15971
|
+
for (const m of measures) addEntry(m, 'metric', `Measure ${m}`, m, 'canonical');
|
|
15972
|
+
for (const d of dimensions) addEntry(d, 'dimension', `Dimension ${d}`, d, 'canonical');
|
|
15973
|
+
for (const v of vocabulary) {
|
|
15974
|
+
if (typeof v === 'string') addEntry(v, 'term');
|
|
15975
|
+
else addEntry(v.display ?? v.word ?? v.label, v.kind ?? 'term', v.sense ?? null, v.reference ?? null, v.relation ?? null);
|
|
15976
|
+
}
|
|
15977
|
+
|
|
15978
|
+
const keywords = [
|
|
15979
|
+
{ display: 'by', kind: 'keyword', sense: 'Group or slice by dimension' },
|
|
15980
|
+
{ display: 'and', kind: 'keyword', sense: 'Add another measure or dimension' },
|
|
15981
|
+
{ display: 'where', kind: 'keyword', sense: 'Filter by dimension member' },
|
|
15982
|
+
{ display: 'top', kind: 'keyword', sense: 'Rank top N members' },
|
|
15983
|
+
{ display: '=', kind: 'operator', sense: 'Equals member value' },
|
|
15984
|
+
{ display: 'in', kind: 'keyword', sense: 'Match any of several members' },
|
|
15985
|
+
{ display: 'vs', kind: 'keyword', sense: 'Compare against another measure or period' },
|
|
15986
|
+
{ display: 'descending', kind: 'keyword', sense: 'Sort highest first' },
|
|
15987
|
+
{ display: 'ascending', kind: 'keyword', sense: 'Sort lowest first' },
|
|
15988
|
+
];
|
|
15989
|
+
for (const kw of keywords) addEntry(kw.display, kw.kind, kw.sense);
|
|
15990
|
+
|
|
15991
|
+
// Learn next-word transitions (bigrams) from sampleQuestions.
|
|
15992
|
+
const bigrams = new Map();
|
|
15993
|
+
for (const q of sampleQuestions) {
|
|
15994
|
+
const tokens = String(q).trim().split(/\s+/).filter(Boolean);
|
|
15995
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
15996
|
+
const prev = tokens[i].replace(/,+$/, '').toLowerCase();
|
|
15997
|
+
const next = tokens[i + 1].replace(/,+$/, '');
|
|
15998
|
+
if (!bigrams.has(prev)) bigrams.set(prev, new Set());
|
|
15999
|
+
bigrams.get(prev).add(next);
|
|
16000
|
+
addEntry(tokens[i].replace(/,+$/, ''), 'term');
|
|
16001
|
+
}
|
|
16002
|
+
if (tokens.length > 0) addEntry(tokens.at(-1).replace(/,+$/, ''), 'term');
|
|
16003
|
+
}
|
|
16004
|
+
|
|
16005
|
+
const getMembers = (dim) => {
|
|
16006
|
+
if (!dim || !members) return [];
|
|
16007
|
+
if (typeof members === 'function') return members(dim) ?? [];
|
|
16008
|
+
const foundKey = Object.keys(members).find(k => k.toLowerCase() === dim.toLowerCase());
|
|
16009
|
+
return foundKey ? members[foundKey] ?? [] : [];
|
|
16010
|
+
};
|
|
16011
|
+
|
|
16012
|
+
return (text) => {
|
|
16013
|
+
const raw = String(text ?? '');
|
|
16014
|
+
const match = raw.match(/^(.*?)(\S*)$/);
|
|
16015
|
+
const before = match ? match[1] : '';
|
|
16016
|
+
const partial = (match ? match[2] : '').toLowerCase();
|
|
16017
|
+
const priorTokens = before.trim().split(/\s+/).filter(Boolean);
|
|
16018
|
+
const lastToken = (priorTokens.at(-1) ?? '').replace(/,+$/, '').toLowerCase();
|
|
16019
|
+
const prevToken = (priorTokens.at(-2) ?? '').replace(/,+$/, '').toLowerCase();
|
|
16020
|
+
|
|
16021
|
+
const results = [];
|
|
16022
|
+
const push = (entry, bonus = 0) => {
|
|
16023
|
+
const word = String(entry.display);
|
|
16024
|
+
if (partial && !word.toLowerCase().startsWith(partial)) return;
|
|
16025
|
+
if (results.some(r => r.display.toLowerCase() === word.toLowerCase())) return;
|
|
16026
|
+
results.push({
|
|
16027
|
+
display: word,
|
|
16028
|
+
completion: AskView._completeWord(raw, word),
|
|
16029
|
+
sense: entry.sense ?? null,
|
|
16030
|
+
reference: entry.reference ?? null,
|
|
16031
|
+
relation: entry.relation ?? null,
|
|
16032
|
+
kind: entry.kind ?? 'suggestion',
|
|
16033
|
+
_score: bonus + (word.toLowerCase() === partial ? 0 : 10),
|
|
16034
|
+
});
|
|
16035
|
+
};
|
|
16036
|
+
|
|
16037
|
+
// 1. After `<dimension> =`, suggest sampled member values of that dimension.
|
|
16038
|
+
if (lastToken === '=' && prevToken) {
|
|
16039
|
+
const dimName = dimensions.find(d => d.toLowerCase() === prevToken) ?? prevToken;
|
|
16040
|
+
for (const val of getMembers(dimName)) {
|
|
16041
|
+
push({
|
|
16042
|
+
display: String(val),
|
|
16043
|
+
kind: 'value',
|
|
16044
|
+
sense: `Member of ${dimName}`,
|
|
16045
|
+
reference: `${dimName} = ${val}`,
|
|
16046
|
+
}, 100);
|
|
16047
|
+
}
|
|
16048
|
+
}
|
|
16049
|
+
|
|
16050
|
+
// 2. Context-sensitive grammar transitions.
|
|
16051
|
+
if (!lastToken) {
|
|
16052
|
+
for (const c of catalog.filter(e => e.kind === 'metric')) push(c, 80);
|
|
16053
|
+
for (const c of catalog.filter(e => e.display === 'top')) push(c, 70);
|
|
16054
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 60);
|
|
16055
|
+
} else if (lastToken === 'by' || lastToken === 'where') {
|
|
16056
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 90);
|
|
16057
|
+
} else if (lastToken === 'and') {
|
|
16058
|
+
const inByClause = priorTokens.some(t => t.toLowerCase() === 'by');
|
|
16059
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'dimension' : 'metric'))) push(c, 90);
|
|
16060
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'metric' : 'dimension'))) push(c, 70);
|
|
16061
|
+
} else if (dimensions.some(d => d.toLowerCase() === lastToken)) {
|
|
16062
|
+
const inWhere = priorTokens.some(t => t.toLowerCase() === 'where');
|
|
16063
|
+
if (inWhere) {
|
|
16064
|
+
for (const c of catalog.filter(e => e.display === '=')) push(c, 95);
|
|
16065
|
+
} else {
|
|
16066
|
+
for (const c of catalog.filter(e => ['and', 'where', 'descending', 'ascending'].includes(e.display))) push(c, 85);
|
|
16067
|
+
}
|
|
16068
|
+
} else if (measures.some(m => m.toLowerCase() === lastToken)) {
|
|
16069
|
+
for (const c of catalog.filter(e => ['by', 'and', 'where', 'vs'].includes(e.display))) push(c, 90);
|
|
16070
|
+
}
|
|
16071
|
+
|
|
16072
|
+
// 3. Learned bigram transitions from sample questions.
|
|
16073
|
+
if (lastToken && bigrams.has(lastToken)) {
|
|
16074
|
+
for (const nextWord of bigrams.get(lastToken)) {
|
|
16075
|
+
const existing = catalog.find(c => c.display.toLowerCase() === nextWord.toLowerCase());
|
|
16076
|
+
push(existing ?? { display: nextWord, kind: 'next word', sense: `Follows "${lastToken}"` }, 88);
|
|
16077
|
+
}
|
|
16078
|
+
}
|
|
16079
|
+
|
|
16080
|
+
// 4. All remaining catalog entries matching the partial prefix.
|
|
16081
|
+
for (const c of catalog) push(c, 20);
|
|
16082
|
+
|
|
16083
|
+
return results
|
|
16084
|
+
.sort((a, b) => b._score - a._score)
|
|
16085
|
+
.map(({ _score, ...rest }) => rest);
|
|
16086
|
+
};
|
|
16087
|
+
}
|
|
16088
|
+
}
|
|
16089
|
+
|
|
16090
|
+
/**
|
|
16091
|
+
* AskBar — a self-contained browser UI component for asking questions with preemptive
|
|
16092
|
+
* next-word prediction in a scrollable list (generalizing the Ask bar from
|
|
16093
|
+
* `semanticengine/examples/browser/index.html` into a reusable RenderEngine capability).
|
|
16094
|
+
*
|
|
16095
|
+
* Follows the same OO encapsulation pattern as `ArchimateDiagramEditor`:
|
|
16096
|
+
* - Owns DOM creation, CSS injection, event wiring, and keyboard/scroll management.
|
|
16097
|
+
* - Separates pure state and prediction logic into `AskView` (`this.view`) so all logic is
|
|
16098
|
+
* DOM-free testable under Node.js.
|
|
16099
|
+
* - Includes a configurable **Predict next word** selector allowing the user (or host) to
|
|
16100
|
+
* choose how many predicted items are visible at once in the scrollable list (e.g. `Off`,
|
|
16101
|
+
* `3`, `5`, `8`, `10`), while scrolling (`overflow-y: auto`) or pressing `↑`/`↓` browses
|
|
16102
|
+
* through the full list of predictions.
|
|
16103
|
+
*/
|
|
16104
|
+
class AskBar {
|
|
16105
|
+
/** Approximate height in pixels of one suggestion row in the scrollable list. */
|
|
16106
|
+
static ITEM_HEIGHT = 44;
|
|
16107
|
+
|
|
16108
|
+
/** Numbers each bar's list, so several bars on one page each name their own (ARIA ids). */
|
|
16109
|
+
static _count = 0;
|
|
16110
|
+
|
|
16111
|
+
/**
|
|
16112
|
+
* @param {HTMLElement|string} container host element or its DOM id
|
|
16113
|
+
* @param {object} [options] all `AskView` options plus UI options:
|
|
16114
|
+
* @param {Document} [options.document] DOM document (defaults to `globalThis.document`)
|
|
16115
|
+
* @param {string} [options.placeholder='Ask a question…'] input placeholder
|
|
16116
|
+
* @param {string} [options.buttonLabel='Run'] submit button label
|
|
16117
|
+
* @param {string} [options.ariaLabel='Question'] input aria-label
|
|
16118
|
+
* @param {boolean} [options.showPredictControl=true] whether to show the configurable "Predict next word" selector
|
|
16119
|
+
* @param {string} [options.predictLabel='Predict next word:'] label for the predict selector
|
|
16120
|
+
* @param {boolean} [options.autoRun=false] whether to run `onAsk` immediately upon mounting
|
|
16121
|
+
* @param {object} [options.resultEngine] optional `RenderEngine` instance whose `resultView()` displays `{ rows, columns, complete }` returned by `onAsk`
|
|
16122
|
+
*/
|
|
16123
|
+
constructor(container, options = {}) {
|
|
16124
|
+
this.options = options;
|
|
16125
|
+
this.doc = options.document ?? container?.ownerDocument ?? globalThis.document ?? null;
|
|
16126
|
+
this.container = typeof container === 'string'
|
|
16127
|
+
? this.doc?.getElementById?.(container) ?? null
|
|
16128
|
+
: container ?? null;
|
|
16129
|
+
this.resultEngine = options.resultEngine ?? null;
|
|
16130
|
+
|
|
16131
|
+
const userOnAsk = options.onAsk;
|
|
16132
|
+
this.listId = `re-ask-list-${++AskBar._count}`;
|
|
16133
|
+
this._blurTimer = null;
|
|
16134
|
+
// An answer (`{ rows, columns, complete }`), or a promise of one, is shown by resultEngine.
|
|
16135
|
+
const show = (res) => {
|
|
16136
|
+
if (res && typeof res === 'object' && Array.isArray(res.rows) && this.resultEngine?.resultView) {
|
|
16137
|
+
this.resultEngine.resultView(res.rows, {
|
|
16138
|
+
columns: res.columns,
|
|
16139
|
+
complete: res.complete,
|
|
16140
|
+
pageSize: options.pageSize ?? 10,
|
|
16141
|
+
});
|
|
16142
|
+
}
|
|
16143
|
+
return res;
|
|
16144
|
+
};
|
|
16145
|
+
this.view = options.view instanceof AskView ? options.view : new AskView({
|
|
16146
|
+
...options,
|
|
16147
|
+
onAsk: (question, view) => {
|
|
16148
|
+
const res = userOnAsk?.(question, view, this);
|
|
16149
|
+
return res && typeof res.then === 'function' ? Promise.resolve(res).then(show) : show(res);
|
|
16150
|
+
},
|
|
16151
|
+
onChange: (view) => {
|
|
16152
|
+
this._syncFromView();
|
|
16153
|
+
options.onChange?.(view, this);
|
|
16154
|
+
},
|
|
16155
|
+
});
|
|
16156
|
+
|
|
16157
|
+
this.root = null;
|
|
16158
|
+
this.inputEl = null;
|
|
16159
|
+
this.suggestEl = null;
|
|
16160
|
+
this.predictSelectEl = null;
|
|
16161
|
+
this.samplesEl = null;
|
|
16162
|
+
|
|
16163
|
+
if (this.container && this.doc) {
|
|
16164
|
+
this.render();
|
|
16165
|
+
if (options.autoRun && this.view.value) {
|
|
16166
|
+
this.view.submit(this.view.value);
|
|
16167
|
+
}
|
|
16168
|
+
}
|
|
16169
|
+
}
|
|
16170
|
+
|
|
16171
|
+
/** Injects the component's scoped stylesheet once per document, build-free and theme-aware. */
|
|
16172
|
+
static _injectStyles(doc) {
|
|
16173
|
+
if (!doc?.head || doc.getElementById?.('re-ask-bar-styles')) return;
|
|
16174
|
+
const style = doc.createElement('style');
|
|
16175
|
+
style.id = 're-ask-bar-styles';
|
|
16176
|
+
style.textContent = `
|
|
16177
|
+
.re-ask-bar { display: flex; flex-direction: column; gap: 6px; font: 14px/1.45 system-ui, sans-serif; color: var(--ws-fg, var(--fg, #1d1d1f)); }
|
|
16178
|
+
.re-ask-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 0; }
|
|
16179
|
+
.re-ask-input-wrapper { position: relative; flex: 1 1 260px; min-width: 180px; }
|
|
16180
|
+
.re-ask-input { width: 100%; box-sizing: border-box; padding: 7px 10px; font: inherit; color: inherit; background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 6px; }
|
|
16181
|
+
.re-ask-input:focus { outline: 2px solid var(--ws-accent, var(--accent, #2b5fd9)); outline-offset: -1px; }
|
|
16182
|
+
.re-ask-submit { padding: 7px 14px; font: inherit; font-weight: 500; color: #fff; background: var(--ws-accent, var(--accent, #2b5fd9)); border: 0; border-radius: 6px; cursor: pointer; }
|
|
16183
|
+
.re-ask-submit:hover { filter: brightness(1.06); }
|
|
16184
|
+
.re-ask-predict-ctrl { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ws-muted, var(--muted, #666)); }
|
|
16185
|
+
.re-ask-predict-select { padding: 5px 7px; font: inherit; color: var(--ws-fg, var(--fg, #1d1d1f)); background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 5px; }
|
|
16186
|
+
.re-ask-suggest-box {
|
|
16187
|
+
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 250;
|
|
16188
|
+
background: var(--ws-panel-bg, var(--bg, #fff));
|
|
16189
|
+
border: 1px solid var(--ws-border, var(--line, #ccc));
|
|
16190
|
+
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
|
16191
|
+
overflow-y: auto; display: flex; flex-direction: column;
|
|
16192
|
+
}
|
|
16193
|
+
.re-ask-suggest-box[hidden] { display: none !important; }
|
|
16194
|
+
.re-ask-suggest-item {
|
|
16195
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
16196
|
+
padding: 7px 10px; cursor: pointer; font-size: 13px; gap: 8px; user-select: none;
|
|
16197
|
+
box-sizing: border-box; min-height: 36px;
|
|
16198
|
+
}
|
|
16199
|
+
.re-ask-suggest-item:hover, .re-ask-suggest-item.active {
|
|
16200
|
+
background: var(--ws-accent, var(--accent, #2b5fd9)); color: #fff;
|
|
16201
|
+
}
|
|
16202
|
+
.re-ask-suggest-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
16203
|
+
.re-ask-suggest-text { font-family: ui-monospace, monospace; font-size: 13px; }
|
|
16204
|
+
.re-ask-suggest-sense { font-size: 11px; color: var(--ws-muted, var(--muted, #6b6b70)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
16205
|
+
.re-ask-suggest-item:hover .re-ask-suggest-sense,
|
|
16206
|
+
.re-ask-suggest-item.active .re-ask-suggest-sense { color: rgba(255, 255, 255, 0.85); }
|
|
16207
|
+
.re-ask-suggest-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
|
|
16208
|
+
.re-ask-suggest-badge {
|
|
16209
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
|
16210
|
+
padding: 2px 6px; border-radius: 4px;
|
|
16211
|
+
background: var(--ws-border, var(--line, #e0e0e4));
|
|
16212
|
+
color: var(--ws-muted, var(--muted, #555)); flex-shrink: 0;
|
|
16213
|
+
}
|
|
16214
|
+
.re-ask-suggest-item:hover .re-ask-suggest-badge,
|
|
16215
|
+
.re-ask-suggest-item.active .re-ask-suggest-badge {
|
|
16216
|
+
background: rgba(255, 255, 255, 0.25); color: #fff;
|
|
16217
|
+
}
|
|
16218
|
+
.re-ask-samples { font-size: 12px; color: var(--ws-muted, var(--muted, #666)); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
16219
|
+
.re-ask-sample-pill {
|
|
16220
|
+
font-family: ui-monospace, monospace; font-size: 11.5px;
|
|
16221
|
+
padding: 2px 6px; border-radius: 4px; cursor: pointer;
|
|
16222
|
+
background: rgba(127,127,127,0.12); border: 1px solid var(--ws-border, var(--line, #ddd));
|
|
16223
|
+
color: var(--ws-fg, var(--fg, #222));
|
|
16224
|
+
}
|
|
16225
|
+
.re-ask-sample-pill:hover { border-color: var(--ws-accent, var(--accent, #2b5fd9)); }
|
|
16226
|
+
`;
|
|
16227
|
+
doc.head.appendChild(style);
|
|
16228
|
+
}
|
|
16229
|
+
|
|
16230
|
+
/** Builds the AskBar DOM inside `this.container`. */
|
|
16231
|
+
render() {
|
|
16232
|
+
if (!this.container || !this.doc) return this;
|
|
16233
|
+
AskBar._injectStyles(this.doc);
|
|
16234
|
+
|
|
16235
|
+
const root = this.doc.createElement('div');
|
|
16236
|
+
root.className = 're-ask-bar';
|
|
16237
|
+
|
|
16238
|
+
const form = this.doc.createElement('form');
|
|
16239
|
+
form.className = 're-ask-form';
|
|
16240
|
+
|
|
16241
|
+
const wrapper = this.doc.createElement('div');
|
|
16242
|
+
wrapper.className = 're-ask-input-wrapper';
|
|
16243
|
+
|
|
16244
|
+
const input = this.doc.createElement('input');
|
|
16245
|
+
input.className = 're-ask-input';
|
|
16246
|
+
input.type = 'text';
|
|
16247
|
+
input.value = this.view.value;
|
|
16248
|
+
input.placeholder = this.options.placeholder ?? 'Ask a question…';
|
|
16249
|
+
input.setAttribute('aria-label', this.options.ariaLabel ?? 'Question');
|
|
16250
|
+
input.setAttribute('autocomplete', 'off');
|
|
16251
|
+
input.setAttribute('spellcheck', 'false');
|
|
16252
|
+
// A combobox over its list of completions (WAI-ARIA 1.2), so a screen reader follows ↑/↓.
|
|
16253
|
+
input.setAttribute('role', 'combobox');
|
|
16254
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
16255
|
+
input.setAttribute('aria-controls', this.listId);
|
|
16256
|
+
input.setAttribute('aria-expanded', 'false');
|
|
16257
|
+
|
|
16258
|
+
const suggestBox = this.doc.createElement('div');
|
|
16259
|
+
suggestBox.className = 're-ask-suggest-box';
|
|
16260
|
+
suggestBox.id = this.listId;
|
|
16261
|
+
suggestBox.hidden = true;
|
|
16262
|
+
suggestBox.setAttribute('role', 'listbox');
|
|
16263
|
+
suggestBox.setAttribute('aria-label', 'Suggested completions');
|
|
16264
|
+
|
|
16265
|
+
wrapper.appendChild(input);
|
|
16266
|
+
wrapper.appendChild(suggestBox);
|
|
16267
|
+
form.appendChild(wrapper);
|
|
16268
|
+
|
|
16269
|
+
const submitBtn = this.doc.createElement('button');
|
|
16270
|
+
submitBtn.type = 'submit';
|
|
16271
|
+
submitBtn.className = 're-ask-submit';
|
|
16272
|
+
submitBtn.textContent = this.options.buttonLabel ?? 'Run';
|
|
16273
|
+
form.appendChild(submitBtn);
|
|
16274
|
+
|
|
16275
|
+
if (this.options.showPredictControl !== false) {
|
|
16276
|
+
const ctrlLabel = this.doc.createElement('label');
|
|
16277
|
+
ctrlLabel.className = 're-ask-predict-ctrl';
|
|
16278
|
+
ctrlLabel.textContent = this.options.predictLabel ?? 'Predict next word:';
|
|
16279
|
+
|
|
16280
|
+
const select = this.doc.createElement('select');
|
|
16281
|
+
select.className = 're-ask-predict-select';
|
|
16282
|
+
select.setAttribute('aria-label', 'Predict next word in scrollable list');
|
|
16283
|
+
for (const choice of this.view.predictChoices) {
|
|
16284
|
+
const n = Number(choice);
|
|
16285
|
+
const text = n <= 0 ? 'Off' : `${n} visible (scrollable)`;
|
|
16286
|
+
const opt = this.doc.createElement('option');
|
|
16287
|
+
opt.value = String(n);
|
|
16288
|
+
opt.textContent = text;
|
|
16289
|
+
if (n === this.view.predictCount) opt.selected = true;
|
|
16290
|
+
select.appendChild(opt);
|
|
16291
|
+
}
|
|
16292
|
+
select.addEventListener('change', () => {
|
|
16293
|
+
this.view.setPredictCount(Number(select.value));
|
|
16294
|
+
});
|
|
16295
|
+
ctrlLabel.appendChild(select);
|
|
16296
|
+
form.appendChild(ctrlLabel);
|
|
16297
|
+
this.predictSelectEl = select;
|
|
16298
|
+
}
|
|
16299
|
+
|
|
16300
|
+
root.appendChild(form);
|
|
16301
|
+
|
|
16302
|
+
if (this.view.samples.length > 0) {
|
|
16303
|
+
const samplesRow = this.doc.createElement('div');
|
|
16304
|
+
samplesRow.className = 're-ask-samples';
|
|
16305
|
+
const prefix = this.doc.createElement('span');
|
|
16306
|
+
prefix.textContent = 'Try:';
|
|
16307
|
+
samplesRow.appendChild(prefix);
|
|
16308
|
+
for (const q of this.view.samples) {
|
|
16309
|
+
const pill = this.doc.createElement('code');
|
|
16310
|
+
pill.className = 're-ask-sample-pill';
|
|
16311
|
+
pill.textContent = q;
|
|
16312
|
+
pill.addEventListener('click', () => {
|
|
16313
|
+
this.view.submit(q);
|
|
16314
|
+
});
|
|
16315
|
+
samplesRow.appendChild(pill);
|
|
16316
|
+
}
|
|
16317
|
+
root.appendChild(samplesRow);
|
|
16318
|
+
this.samplesEl = samplesRow;
|
|
16319
|
+
}
|
|
16320
|
+
|
|
16321
|
+
this.root = root;
|
|
16322
|
+
this.inputEl = input;
|
|
16323
|
+
this.suggestEl = suggestBox;
|
|
16324
|
+
this._wireEvents(form, input);
|
|
16325
|
+
this.container.replaceChildren ? this.container.replaceChildren(root) : (this.container.innerHTML = '', this.container.appendChild(root));
|
|
16326
|
+
this._syncFromView();
|
|
16327
|
+
return this;
|
|
16328
|
+
}
|
|
16329
|
+
|
|
16330
|
+
_wireEvents(form, input) {
|
|
16331
|
+
input.addEventListener('input', () => {
|
|
16332
|
+
this.view.setInput(input.value, { suggest: true });
|
|
16333
|
+
});
|
|
16334
|
+
input.addEventListener('focus', () => {
|
|
16335
|
+
if (this.view.predict) this.view.refresh();
|
|
16336
|
+
});
|
|
16337
|
+
input.addEventListener('blur', () => {
|
|
16338
|
+
this._blurTimer = setTimeout(() => { this._blurTimer = null; this.view.close(); }, 150);
|
|
16339
|
+
});
|
|
16340
|
+
input.addEventListener('keydown', (e) => {
|
|
16341
|
+
if (!this.view.open || this.view.items.length === 0) {
|
|
16342
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
16343
|
+
this.view.refresh();
|
|
16344
|
+
}
|
|
16345
|
+
return;
|
|
16346
|
+
}
|
|
16347
|
+
if (e.key === 'ArrowDown') {
|
|
16348
|
+
e.preventDefault();
|
|
16349
|
+
this.view.move(1);
|
|
16350
|
+
} else if (e.key === 'ArrowUp') {
|
|
16351
|
+
e.preventDefault();
|
|
16352
|
+
this.view.move(-1);
|
|
16353
|
+
} else if (e.key === 'Tab') {
|
|
16354
|
+
e.preventDefault();
|
|
16355
|
+
this.view.accept(this.view.selectedIndex >= 0 ? this.view.selectedIndex : 0);
|
|
16356
|
+
this.inputEl?.focus?.();
|
|
16357
|
+
} else if (e.key === 'Enter' && this.view.selectedIndex >= 0) {
|
|
16358
|
+
e.preventDefault();
|
|
16359
|
+
this.view.accept(this.view.selectedIndex);
|
|
16360
|
+
this.inputEl?.focus?.();
|
|
16361
|
+
} else if (e.key === 'Escape') {
|
|
16362
|
+
this.view.close();
|
|
16363
|
+
}
|
|
16364
|
+
});
|
|
16365
|
+
form.addEventListener('submit', (e) => {
|
|
16366
|
+
e.preventDefault();
|
|
16367
|
+
this.view.submit(input.value);
|
|
16368
|
+
});
|
|
16369
|
+
}
|
|
16370
|
+
|
|
16371
|
+
/** Swaps where suggestions come from (`AskView.setSuggest`): another model or ontology. */
|
|
16372
|
+
setSuggest(suggest) {
|
|
16373
|
+
this.view.setSuggest(suggest);
|
|
16374
|
+
return this;
|
|
16375
|
+
}
|
|
16376
|
+
|
|
16377
|
+
/** Sets the question shown, without suggesting. */
|
|
16378
|
+
setValue(text) {
|
|
16379
|
+
this.view.setInput(text, { suggest: false });
|
|
16380
|
+
return this;
|
|
16381
|
+
}
|
|
16382
|
+
|
|
16383
|
+
/** Puts the cursor in the question. */
|
|
16384
|
+
focus() {
|
|
16385
|
+
this.inputEl?.focus?.();
|
|
16386
|
+
return this;
|
|
16387
|
+
}
|
|
16388
|
+
|
|
16389
|
+
/** Takes the bar out of its container; a pending close is cancelled. */
|
|
16390
|
+
destroy() {
|
|
16391
|
+
if (this._blurTimer !== null) clearTimeout(this._blurTimer);
|
|
16392
|
+
this._blurTimer = null;
|
|
16393
|
+
this.root?.remove?.();
|
|
16394
|
+
this.root = this.inputEl = this.suggestEl = this.predictSelectEl = this.samplesEl = null;
|
|
16395
|
+
}
|
|
16396
|
+
|
|
16397
|
+
_syncFromView() {
|
|
16398
|
+
if (!this.inputEl || !this.suggestEl) return;
|
|
16399
|
+
if (this.inputEl.value !== this.view.value) {
|
|
16400
|
+
this.inputEl.value = this.view.value;
|
|
16401
|
+
}
|
|
16402
|
+
if (this.predictSelectEl) {
|
|
16403
|
+
const val = String(this.view.predictCount);
|
|
16404
|
+
if (this.predictSelectEl.value !== val) this.predictSelectEl.value = val;
|
|
16405
|
+
}
|
|
16406
|
+
|
|
16407
|
+
const active = this.view.selectedIndex;
|
|
16408
|
+
if (active >= 0 && this.view.open) this.inputEl.setAttribute('aria-activedescendant', `${this.listId}-${active}`);
|
|
16409
|
+
else this.inputEl.removeAttribute?.('aria-activedescendant');
|
|
16410
|
+
|
|
16411
|
+
if (!this.view.open || !this.view.predict || this.view.items.length === 0) {
|
|
16412
|
+
this.inputEl.setAttribute('aria-expanded', 'false');
|
|
16413
|
+
this.suggestEl.hidden = true;
|
|
16414
|
+
if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
|
|
16415
|
+
return;
|
|
16416
|
+
}
|
|
16417
|
+
|
|
16418
|
+
this.suggestEl.style.maxHeight = `${Math.max(1, this.view.visibleCount) * AskBar.ITEM_HEIGHT}px`;
|
|
16419
|
+
if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
|
|
16420
|
+
|
|
16421
|
+
this.view.items.forEach((item, idx) => {
|
|
16422
|
+
const row = this.doc.createElement('div');
|
|
16423
|
+
row.className = `re-ask-suggest-item${idx === this.view.selectedIndex ? ' active' : ''}`;
|
|
16424
|
+
row.dataset.index = String(idx);
|
|
16425
|
+
row.id = `${this.listId}-${idx}`;
|
|
16426
|
+
row.setAttribute('role', 'option');
|
|
16427
|
+
row.setAttribute('aria-selected', String(idx === this.view.selectedIndex));
|
|
16428
|
+
|
|
16429
|
+
const mainCol = this.doc.createElement('div');
|
|
16430
|
+
mainCol.className = 're-ask-suggest-main';
|
|
16431
|
+
|
|
16432
|
+
const textSpan = this.doc.createElement('span');
|
|
16433
|
+
textSpan.className = 're-ask-suggest-text';
|
|
16434
|
+
textSpan.textContent = item.display;
|
|
16435
|
+
mainCol.appendChild(textSpan);
|
|
16436
|
+
|
|
16437
|
+
if (item.sense) {
|
|
16438
|
+
const senseSpan = this.doc.createElement('span');
|
|
16439
|
+
senseSpan.className = 're-ask-suggest-sense';
|
|
16440
|
+
senseSpan.textContent = `${item.sense}${item.reference ? ` → ${item.reference}` : ''}`;
|
|
16441
|
+
mainCol.appendChild(senseSpan);
|
|
16442
|
+
}
|
|
16443
|
+
|
|
16444
|
+
const badges = this.doc.createElement('div');
|
|
16445
|
+
badges.className = 're-ask-suggest-badges';
|
|
16446
|
+
|
|
16447
|
+
if (item.relation && item.relation !== 'canonical' && item.relation !== 'function') {
|
|
16448
|
+
const relBadge = this.doc.createElement('span');
|
|
16449
|
+
relBadge.className = 're-ask-suggest-badge';
|
|
16450
|
+
relBadge.textContent = item.relation;
|
|
16451
|
+
badges.appendChild(relBadge);
|
|
16452
|
+
}
|
|
16453
|
+
if (item.kind) {
|
|
16454
|
+
const kindBadge = this.doc.createElement('span');
|
|
16455
|
+
kindBadge.className = 're-ask-suggest-badge';
|
|
16456
|
+
kindBadge.textContent = item.kind;
|
|
16457
|
+
badges.appendChild(kindBadge);
|
|
16458
|
+
}
|
|
16459
|
+
|
|
16460
|
+
row.appendChild(mainCol);
|
|
16461
|
+
row.appendChild(badges);
|
|
16462
|
+
row.addEventListener('mousedown', (e) => {
|
|
16463
|
+
e.preventDefault();
|
|
16464
|
+
this.view.accept(idx);
|
|
16465
|
+
this.inputEl?.focus?.();
|
|
16466
|
+
});
|
|
16467
|
+
this.suggestEl.appendChild(row);
|
|
16468
|
+
if (idx === this.view.selectedIndex && typeof row.scrollIntoView === 'function') {
|
|
16469
|
+
row.scrollIntoView({ block: 'nearest' });
|
|
16470
|
+
}
|
|
16471
|
+
});
|
|
16472
|
+
this.suggestEl.hidden = false;
|
|
16473
|
+
this.inputEl.setAttribute('aria-expanded', 'true');
|
|
16474
|
+
}
|
|
14499
16475
|
}
|
|
14500
16476
|
|
|
14501
16477
|
/**
|
|
@@ -14517,7 +16493,7 @@
|
|
|
14517
16493
|
} = options;
|
|
14518
16494
|
const isMulti = Array.isArray(data[0]?.values);
|
|
14519
16495
|
const series = isMulti ? data : [{ name: 'default', values: data }];
|
|
14520
|
-
const axes = series[0].values.map(v => v.axis);
|
|
16496
|
+
const axes = series[0].values.map(v => v.axis ?? v.label);
|
|
14521
16497
|
const n = axes.length;
|
|
14522
16498
|
const palette = new ColorPalette(colors);
|
|
14523
16499
|
const axisMax = axes.map((_, ai) => Math.max(...series.map(s => s.values[ai]?.value ?? 0)) || 1);
|
|
@@ -14555,9 +16531,10 @@
|
|
|
14555
16531
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
14556
16532
|
this.renderer.path(path, `rgba(${r},${g},${b},0.25)`, { color, width: 2 }, { tooltip: s.name });
|
|
14557
16533
|
pts.forEach((p, i) => {
|
|
14558
|
-
|
|
14559
|
-
|
|
14560
|
-
|
|
16534
|
+
const axisLabel = s.values[i].axis ?? s.values[i].label;
|
|
16535
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
16536
|
+
tooltip: `${s.name} — ${axisLabel}: ${s.values[i].value}`,
|
|
16537
|
+
}), { label: axisLabel, series: isMulti ? s.name : null });
|
|
14561
16538
|
});
|
|
14562
16539
|
});
|
|
14563
16540
|
|
|
@@ -14606,7 +16583,7 @@
|
|
|
14606
16583
|
const bottomRight = bottomLeft + bottomWidth;
|
|
14607
16584
|
|
|
14608
16585
|
const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
|
|
14609
|
-
this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
|
|
16586
|
+
this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
|
|
14610
16587
|
this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
|
|
14611
16588
|
textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
|
|
14612
16589
|
});
|
|
@@ -14670,9 +16647,9 @@
|
|
|
14670
16647
|
const top = toY(Math.max(band.from, band.to));
|
|
14671
16648
|
const bottom = toY(Math.min(band.from, band.to));
|
|
14672
16649
|
const color = band.isTotal ? totalColor : (band.to >= band.from ? upColor : downColor);
|
|
14673
|
-
this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
16650
|
+
this._datum(this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
14674
16651
|
tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
|
|
14675
|
-
});
|
|
16652
|
+
}), { label: d.label });
|
|
14676
16653
|
if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
|
|
14677
16654
|
});
|
|
14678
16655
|
}
|
|
@@ -15801,12 +17778,12 @@
|
|
|
15801
17778
|
const barBaseY = y + height;
|
|
15802
17779
|
const color = palette.colorAt(i);
|
|
15803
17780
|
|
|
15804
|
-
this.renderer.rect(
|
|
17781
|
+
this._datum(this.renderer.rect(
|
|
15805
17782
|
barX, barBaseY - barHeight, barWidth, barHeight,
|
|
15806
17783
|
color,
|
|
15807
17784
|
{ color: '#333', width: 1 },
|
|
15808
17785
|
{ tooltip: `${d.label}: ${d.value}` },
|
|
15809
|
-
);
|
|
17786
|
+
), { label: d.label });
|
|
15810
17787
|
|
|
15811
17788
|
// Top face
|
|
15812
17789
|
const topPath = `M${barX},${barBaseY - barHeight} L${barX + barWidth},${barBaseY - barHeight} L${barX + barWidth + depth},${barBaseY - barHeight - depth} L${barX + depth},${barBaseY - barHeight - depth} Z`;
|
|
@@ -15890,8 +17867,9 @@
|
|
|
15890
17867
|
|
|
15891
17868
|
/**
|
|
15892
17869
|
* Draw one 3D box through the orbit camera. Returns average corner depth.
|
|
17870
|
+
* When `datum` is supplied (`{ label, series? }`), each face is recorded via `_datum()`.
|
|
15893
17871
|
*/
|
|
15894
|
-
drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen) {
|
|
17872
|
+
drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen, datum = null) {
|
|
15895
17873
|
const camera = this.camera;
|
|
15896
17874
|
const faceXSign = Math.sin(camera.theta) >= 0 ? 1 : -1;
|
|
15897
17875
|
const faceZSign = Math.cos(camera.theta) >= 0 ? 1 : -1;
|
|
@@ -15905,7 +17883,8 @@
|
|
|
15905
17883
|
|
|
15906
17884
|
const drawFace = (pts, shade) => {
|
|
15907
17885
|
const d = `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y} L${pts[2].x},${pts[2].y} L${pts[3].x},${pts[3].y} Z`;
|
|
15908
|
-
renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
|
|
17886
|
+
const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
|
|
17887
|
+
if (datum) this._datum(el, datum);
|
|
15909
17888
|
};
|
|
15910
17889
|
drawFace(top, 0.15);
|
|
15911
17890
|
drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
|
|
@@ -16025,7 +18004,9 @@
|
|
|
16025
18004
|
}).sort((a, b) => b.depth - a.depth);
|
|
16026
18005
|
|
|
16027
18006
|
for (const bar of bars) {
|
|
16028
|
-
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen
|
|
18007
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen, {
|
|
18008
|
+
label: bar.d.x, series: bar.d.z, x: bar.d.x, z: bar.d.z,
|
|
18009
|
+
});
|
|
16029
18010
|
}
|
|
16030
18011
|
|
|
16031
18012
|
new Legend({
|
|
@@ -16076,13 +18057,16 @@
|
|
|
16076
18057
|
const segHeight = bar.d.values[j] / stackMax;
|
|
16077
18058
|
const y0 = yCursor, y1 = yCursor + segHeight;
|
|
16078
18059
|
yCursor = y1;
|
|
16079
|
-
|
|
18060
|
+
const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
|
|
18061
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
|
|
18062
|
+
label: bar.d.x, series, x: bar.d.x, z: bar.d.z,
|
|
18063
|
+
});
|
|
16080
18064
|
}
|
|
16081
18065
|
}
|
|
16082
18066
|
|
|
16083
18067
|
const maxSegments = Math.max(...data.map(d => d.values.length));
|
|
16084
18068
|
new Legend({
|
|
16085
|
-
items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
18069
|
+
items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
16086
18070
|
x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
|
|
16087
18071
|
}).draw(this.renderer);
|
|
16088
18072
|
}
|
|
@@ -16593,6 +18577,14 @@
|
|
|
16593
18577
|
const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
|
|
16594
18578
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
16595
18579
|
this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
|
|
18580
|
+
if (typeof this.renderer.circle === 'function') {
|
|
18581
|
+
topPts.forEach((p, i) => {
|
|
18582
|
+
const d = s.values[i];
|
|
18583
|
+
this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
|
|
18584
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
18585
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
18586
|
+
});
|
|
18587
|
+
}
|
|
16596
18588
|
});
|
|
16597
18589
|
|
|
16598
18590
|
// X-axis labels.
|
|
@@ -16671,9 +18663,9 @@
|
|
|
16671
18663
|
const rOuter = innerRadius + barLen;
|
|
16672
18664
|
const color = d.color ?? palette.colorAt(i);
|
|
16673
18665
|
const path = ShapePaths.donutSlicePath(cx, cy, rOuter, innerRadius, a0, a1);
|
|
16674
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
18666
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
16675
18667
|
tooltip: `${d.label}: ${d.value}`,
|
|
16676
|
-
});
|
|
18668
|
+
}), { label: d.label });
|
|
16677
18669
|
|
|
16678
18670
|
// Category label around the outer track.
|
|
16679
18671
|
const midAngle = (a0 + a1) / 2;
|
|
@@ -16779,15 +18771,15 @@
|
|
|
16779
18771
|
|
|
16780
18772
|
// Left bar grows from center to the left.
|
|
16781
18773
|
if (leftW > 0) {
|
|
16782
|
-
this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
18774
|
+
this._datum(this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
16783
18775
|
tooltip: `${d.label} — ${leftLabel}: ${d.left}`,
|
|
16784
|
-
});
|
|
18776
|
+
}), { label: d.label, series: leftLabel });
|
|
16785
18777
|
}
|
|
16786
18778
|
// Right bar grows from center to the right.
|
|
16787
18779
|
if (rightW > 0) {
|
|
16788
|
-
this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
18780
|
+
this._datum(this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
16789
18781
|
tooltip: `${d.label} — ${rightLabel}: ${d.right}`,
|
|
16790
|
-
});
|
|
18782
|
+
}), { label: d.label, series: rightLabel });
|
|
16791
18783
|
}
|
|
16792
18784
|
|
|
16793
18785
|
// Row label centered on the axis.
|
|
@@ -16927,18 +18919,18 @@
|
|
|
16927
18919
|
const linkH = l.value * scale;
|
|
16928
18920
|
const path = this._linkPath(n.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
|
|
16929
18921
|
const { r, g, b: bl } = palette.hexToRgb(n.color);
|
|
16930
|
-
this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
|
|
18922
|
+
this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
|
|
16931
18923
|
tooltip: `${n.label} → ${t.label}: ${l.value}`,
|
|
16932
|
-
});
|
|
18924
|
+
}), { label: n.label, series: t.label });
|
|
16933
18925
|
outY += linkH;
|
|
16934
18926
|
});
|
|
16935
18927
|
});
|
|
16936
18928
|
|
|
16937
18929
|
// Draw nodes.
|
|
16938
18930
|
nodes.forEach(n => {
|
|
16939
|
-
this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
|
|
18931
|
+
this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
|
|
16940
18932
|
tooltip: `${n.label}: ${n.value}`,
|
|
16941
|
-
});
|
|
18933
|
+
}), { label: n.label });
|
|
16942
18934
|
this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
|
|
16943
18935
|
textAnchor: 'middle', fontSize: '10px', fill: '#333',
|
|
16944
18936
|
});
|
|
@@ -17078,19 +19070,19 @@
|
|
|
17078
19070
|
const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
|
|
17079
19071
|
const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
|
|
17080
19072
|
const width = Math.max(1, (l.value / maxLink) * maxStroke);
|
|
17081
|
-
this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
19073
|
+
this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
17082
19074
|
color: s.color, width,
|
|
17083
19075
|
}, {
|
|
17084
19076
|
tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
|
|
17085
|
-
});
|
|
19077
|
+
}), { label: s.label, series: t.label });
|
|
17086
19078
|
});
|
|
17087
19079
|
|
|
17088
19080
|
// Draw node arcs.
|
|
17089
19081
|
nodes.forEach(n => {
|
|
17090
19082
|
const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
|
|
17091
|
-
this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
19083
|
+
this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
17092
19084
|
tooltip: `${n.label}: ${n.value}`,
|
|
17093
|
-
});
|
|
19085
|
+
}), { label: n.label });
|
|
17094
19086
|
|
|
17095
19087
|
// Label outside the circle.
|
|
17096
19088
|
const lx = cx + (radius + 16) * Math.cos(n.mid);
|
|
@@ -17180,10 +19172,10 @@
|
|
|
17180
19172
|
if (!pos) return; // Could not place; skip.
|
|
17181
19173
|
placed.push({ x: pos.x, y: pos.y, w: box.w, h: box.h });
|
|
17182
19174
|
const color = palette.colorAt(i);
|
|
17183
|
-
this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
19175
|
+
this._datum(this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
17184
19176
|
textAnchor, fontSize: `${fontSize}px`, fill: color, fontFamily,
|
|
17185
19177
|
tooltip: `${d.text}: ${d.value}`,
|
|
17186
|
-
});
|
|
19178
|
+
}), { label: d.text });
|
|
17187
19179
|
});
|
|
17188
19180
|
}
|
|
17189
19181
|
|
|
@@ -17299,11 +19291,11 @@
|
|
|
17299
19291
|
pathData += `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`;
|
|
17300
19292
|
});
|
|
17301
19293
|
const label = row.label || (hasCategories ? row.category : null);
|
|
17302
|
-
this.renderer.path(pathData, 'none', {
|
|
19294
|
+
this._datum(this.renderer.path(pathData, 'none', {
|
|
17303
19295
|
color, width: lineWidth,
|
|
17304
19296
|
opacity: lineOpacity,
|
|
17305
19297
|
tooltip: label ? `${label}: ${dimensions.map(d => `${d}=${this._fmt(row[d])}`).join(', ')}` : undefined,
|
|
17306
|
-
});
|
|
19298
|
+
}), { label: label ?? String(rowIndex) });
|
|
17307
19299
|
});
|
|
17308
19300
|
|
|
17309
19301
|
if (legendVisible && hasCategories) {
|
|
@@ -17535,9 +19527,12 @@
|
|
|
17535
19527
|
const cellX = x + xi * cellW;
|
|
17536
19528
|
const cellY = y + yi * cellH;
|
|
17537
19529
|
const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
|
|
17538
|
-
this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
19530
|
+
const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
17539
19531
|
tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
|
|
17540
19532
|
});
|
|
19533
|
+
if (v != null) {
|
|
19534
|
+
this._datum(cellRect, { label: yc, series: xc, x: xc, y: yc });
|
|
19535
|
+
}
|
|
17541
19536
|
if (showValues && v != null) {
|
|
17542
19537
|
this.renderer.text(Axis.formatValue(v), cellX + cellW / 2, cellY + cellH / 2, {
|
|
17543
19538
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: ColorPalette.isDark(color) ? '#fff' : '#333',
|
|
@@ -52181,6 +54176,39 @@ ${theirs}` : theirs;
|
|
|
52181
54176
|
const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
|
|
52182
54177
|
back?.addEventListener?.('click', () => this._drawResultTable(view, options));
|
|
52183
54178
|
if (back?.style) back.style.cursor = 'pointer';
|
|
54179
|
+
const choices = view.chartChoices();
|
|
54180
|
+
if (choices.length > 1) {
|
|
54181
|
+
const pick = this.renderer.text(`${choice.label} ▾`, 68, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
|
|
54182
|
+
const show = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
|
|
54183
|
+
pick?.addEventListener?.('click', (e) => show(e.clientX, e.clientY, choices.map(c => ({
|
|
54184
|
+
label: `${c.label}${c.key === choice.key ? ' ✓' : ''}`,
|
|
54185
|
+
action: () => this._drawResultChart(view, c, options),
|
|
54186
|
+
}))));
|
|
54187
|
+
if (pick?.style) pick.style.cursor = 'pointer';
|
|
54188
|
+
}
|
|
54189
|
+
}
|
|
54190
|
+
|
|
54191
|
+
/**
|
|
54192
|
+
* Mounts an interactive `AskBar` (question input, configurable next-word prediction in a
|
|
54193
|
+
* scrollable list, semantic sense/reference/relation badges, and sample query pills) into
|
|
54194
|
+
* `container` (or links `resultEngine: this` when called as `engine.askBar(container, options)`).
|
|
54195
|
+
* @param {HTMLElement|string|object} containerOrOptions host container element/id, or options when mounting into `this.container`
|
|
54196
|
+
* @param {object} [options] `AskBar` / `AskView` options (`suggest`, `measures`, `dimensions`, `members`, `sampleQuestions`, `visibleCount`, `predictChoices`, `onAsk`, etc.)
|
|
54197
|
+
* @returns {AskBar}
|
|
54198
|
+
*/
|
|
54199
|
+
askBar(containerOrOptions, options = {}) {
|
|
54200
|
+
const isPlainOptions = containerOrOptions
|
|
54201
|
+
&& typeof containerOrOptions === 'object'
|
|
54202
|
+
&& !containerOrOptions.nodeType
|
|
54203
|
+
&& typeof containerOrOptions.appendChild !== 'function';
|
|
54204
|
+
const container = isPlainOptions ? this.container : containerOrOptions;
|
|
54205
|
+
const opts = isPlainOptions ? containerOrOptions : options;
|
|
54206
|
+
return new AskBar(container, { resultEngine: this, ...opts });
|
|
54207
|
+
}
|
|
54208
|
+
|
|
54209
|
+
/** Static helper: `RenderEngine.askBar(container, options)` constructs and mounts an `AskBar`. */
|
|
54210
|
+
static askBar(container, options = {}) {
|
|
54211
|
+
return new AskBar(container, options);
|
|
52184
54212
|
}
|
|
52185
54213
|
|
|
52186
54214
|
/**
|
|
@@ -52231,7 +54259,8 @@ ${theirs}` : theirs;
|
|
|
52231
54259
|
pivotChart(view, options = {}) {
|
|
52232
54260
|
if (!this.renderer) return null;
|
|
52233
54261
|
const chart = new PivotChart(view, options);
|
|
52234
|
-
chart.
|
|
54262
|
+
chart.redraw = () => this._drawPivotChart(chart, options);
|
|
54263
|
+
chart.detach = view.listen(chart.redraw);
|
|
52235
54264
|
this._drawPivotChart(chart, options);
|
|
52236
54265
|
return chart;
|
|
52237
54266
|
}
|
|
@@ -52242,15 +54271,19 @@ ${theirs}` : theirs;
|
|
|
52242
54271
|
if (choice === null) {
|
|
52243
54272
|
this.renderer.clear();
|
|
52244
54273
|
this.renderer.text('No chart fits what the grid shows', 8, 14, { fill: '#666', fontSize: '12px', verticalAlign: 'middle' });
|
|
54274
|
+
options.onDraw?.(null, chart);
|
|
52245
54275
|
return;
|
|
52246
54276
|
}
|
|
52247
54277
|
// No title unless one is given: the grid beside it says what is charted, and the link below
|
|
52248
54278
|
// names the chart (with the chooser's reason as its tooltip).
|
|
52249
54279
|
this.renderChartType(choice.key, choice.data, {
|
|
54280
|
+
...choice.options,
|
|
52250
54281
|
title: options.title,
|
|
52251
54282
|
legend: true, // a series per column member: which colour is which must be said
|
|
52252
54283
|
onDatumClick: (datum) => chart.click(datum),
|
|
54284
|
+
onNodeClick: (node) => chart.click({ label: node.label }),
|
|
52253
54285
|
});
|
|
54286
|
+
options.onDraw?.(choice, chart);
|
|
52254
54287
|
// Which chart, and the menu to change it, drawn over the chart as resultView's "◂ Table" is.
|
|
52255
54288
|
if (!this.renderer.supportsSceneGraph) return;
|
|
52256
54289
|
const pick = this.renderer.text(`${choice.label} ▾`, 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
|
|
@@ -52268,6 +54301,57 @@ ${theirs}` : theirs;
|
|
|
52268
54301
|
this.fitToContainer();
|
|
52269
54302
|
}
|
|
52270
54303
|
|
|
54304
|
+
/**
|
|
54305
|
+
* Renders an icicle chart — rectangular hierarchical partition chart (top-down bands by depth,
|
|
54306
|
+
* width proportional to value within each parent's span).
|
|
54307
|
+
* @param {Array<{label: string, value?: number, children?: Array}>} data
|
|
54308
|
+
* @param {object} [options]
|
|
54309
|
+
*/
|
|
54310
|
+
icicleChart(data, options = {}) {
|
|
54311
|
+
if (!this.renderer) return;
|
|
54312
|
+
this._chart3D = false;
|
|
54313
|
+
this._chartState = { type: 'icicle', data, options };
|
|
54314
|
+
this._renderChart(new IcicleChart(this.renderer, data, options));
|
|
54315
|
+
}
|
|
54316
|
+
|
|
54317
|
+
/**
|
|
54318
|
+
* Renders a Pareto chart — categories sorted descending with a cumulative percentage line (0–100%)
|
|
54319
|
+
* on a right-hand axis and an 80% cutoff reference line.
|
|
54320
|
+
* @param {Array<{label: string, value: number}>} data
|
|
54321
|
+
* @param {object} [options]
|
|
54322
|
+
*/
|
|
54323
|
+
paretoChart(data, options = {}) {
|
|
54324
|
+
if (!this.renderer) return;
|
|
54325
|
+
this._chart3D = false;
|
|
54326
|
+
this._chartState = { type: 'pareto', data, options };
|
|
54327
|
+
this._renderChart(new ParetoChart(this.renderer, data, options));
|
|
54328
|
+
}
|
|
54329
|
+
|
|
54330
|
+
/**
|
|
54331
|
+
* Renders a lollipop chart — stem-and-dot alternative to bars for 1D or multi-series comparisons.
|
|
54332
|
+
* @param {Array<{label: string, value: number}>|Array<{name: string, values: Array<{label: string, value: number}>}>} data
|
|
54333
|
+
* @param {object} [options]
|
|
54334
|
+
*/
|
|
54335
|
+
lollipopChart(data, options = {}) {
|
|
54336
|
+
if (!this.renderer) return;
|
|
54337
|
+
this._chart3D = false;
|
|
54338
|
+
this._chartState = { type: 'lollipop', data, options };
|
|
54339
|
+
this._renderChart(new LollipopChart(this.renderer, data, options));
|
|
54340
|
+
}
|
|
54341
|
+
|
|
54342
|
+
/**
|
|
54343
|
+
* Renders a Marimekko (mosaic) chart — variable-width 100%-stacked columns where column width is
|
|
54344
|
+
* proportional to each category's total and segment height is each series' share within that category.
|
|
54345
|
+
* @param {Array<{name: string, values: Array<{label: string, value: number}>}>|Array<{label: string, values: number[]}>} data
|
|
54346
|
+
* @param {object} [options]
|
|
54347
|
+
*/
|
|
54348
|
+
marimekkoChart(data, options = {}) {
|
|
54349
|
+
if (!this.renderer) return;
|
|
54350
|
+
this._chart3D = false;
|
|
54351
|
+
this._chartState = { type: 'marimekko', data, options };
|
|
54352
|
+
this._renderChart(new MarimekkoChart(this.renderer, data, options));
|
|
54353
|
+
}
|
|
54354
|
+
|
|
52271
54355
|
/**
|
|
52272
54356
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
52273
54357
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -53606,6 +55690,8 @@ ${theirs}` : theirs;
|
|
|
53606
55690
|
exports.ArchimateView = ArchimateView;
|
|
53607
55691
|
exports.Arrangement = Arrangement;
|
|
53608
55692
|
exports.ArrowMarker = ArrowMarker;
|
|
55693
|
+
exports.AskBar = AskBar;
|
|
55694
|
+
exports.AskView = AskView;
|
|
53609
55695
|
exports.AssociationKind = AssociationKind;
|
|
53610
55696
|
exports.AttributedLevel = AttributedLevel;
|
|
53611
55697
|
exports.AzureDevOpsApi = AzureDevOpsApi;
|
|
@@ -53728,6 +55814,7 @@ ${theirs}` : theirs;
|
|
|
53728
55814
|
exports.ICON_DEFS = ICON_DEFS;
|
|
53729
55815
|
exports.ITElementType = ITElementType;
|
|
53730
55816
|
exports.ITIconNotation = ITIconNotation;
|
|
55817
|
+
exports.IcicleChart = IcicleChart;
|
|
53731
55818
|
exports.IconSet = IconSet;
|
|
53732
55819
|
exports.ImageSelector = ImageSelector;
|
|
53733
55820
|
exports.ItemGroup = ItemGroup;
|
|
@@ -53742,10 +55829,12 @@ ${theirs}` : theirs;
|
|
|
53742
55829
|
exports.LocalJsonStore = LocalJsonStore;
|
|
53743
55830
|
exports.LockCommand = LockCommand;
|
|
53744
55831
|
exports.LogicalLevel = LogicalLevel;
|
|
55832
|
+
exports.LollipopChart = LollipopChart;
|
|
53745
55833
|
exports.LowerCommand = LowerCommand;
|
|
53746
55834
|
exports.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS;
|
|
53747
55835
|
exports.MODEL_ELEMENT_MIME = MODEL_ELEMENT_MIME;
|
|
53748
55836
|
exports.ManualInputTemplate = ManualInputTemplate;
|
|
55837
|
+
exports.MarimekkoChart = MarimekkoChart;
|
|
53749
55838
|
exports.Mat4 = Mat4;
|
|
53750
55839
|
exports.MeasuredCategory = MeasuredCategory;
|
|
53751
55840
|
exports.MenuBar = MenuBar;
|
|
@@ -53773,6 +55862,7 @@ ${theirs}` : theirs;
|
|
|
53773
55862
|
exports.PackageTemplate = PackageTemplate;
|
|
53774
55863
|
exports.Palette = Palette;
|
|
53775
55864
|
exports.ParallelogramTemplate = ParallelogramTemplate;
|
|
55865
|
+
exports.ParetoChart = ParetoChart;
|
|
53776
55866
|
exports.PentagonTemplate = PentagonTemplate;
|
|
53777
55867
|
exports.PhysicalLevel = PhysicalLevel;
|
|
53778
55868
|
exports.PivotChart = PivotChart;
|