@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
|
@@ -6788,12 +6788,15 @@
|
|
|
6788
6788
|
},
|
|
6789
6789
|
{
|
|
6790
6790
|
key: 'stackedBar', label: 'Stacked Bar', method: 'stackedBarChart', category: '2d',
|
|
6791
|
-
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend:
|
|
6791
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
6792
|
+
seriesCapable: true,
|
|
6792
6793
|
axes: [
|
|
6793
6794
|
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
6794
|
-
{ role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required:
|
|
6795
|
+
{ role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
|
|
6796
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
|
|
6797
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
|
|
6795
6798
|
],
|
|
6796
|
-
dataShape: "Array<{label, values:number[]}>
|
|
6799
|
+
dataShape: "Array<{label, values:number[]}> or multi-series Array<{name, values:Array<{label,value}>}> — segments stacked from zero, with optional series legend",
|
|
6797
6800
|
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
|
|
6798
6801
|
},
|
|
6799
6802
|
{
|
|
@@ -7034,6 +7037,50 @@
|
|
|
7034
7037
|
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',
|
|
7035
7038
|
streamShape: 'flat',
|
|
7036
7039
|
},
|
|
7040
|
+
{
|
|
7041
|
+
key: 'pareto', label: 'Pareto', method: 'paretoChart', category: '2d',
|
|
7042
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SEMANTIC, camera: CAMERA_MODE.NONE,
|
|
7043
|
+
axes: [
|
|
7044
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
7045
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
|
|
7046
|
+
],
|
|
7047
|
+
dataShape: 'Array<{label, value}> — sorted descending with a right-hand 0-100% cumulative percentage line and 80% cutoff reference line',
|
|
7048
|
+
streamShape: 'flat',
|
|
7049
|
+
},
|
|
7050
|
+
{
|
|
7051
|
+
key: 'lollipop', label: 'Lollipop', method: 'lollipopChart', category: '2d',
|
|
7052
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
7053
|
+
seriesCapable: true,
|
|
7054
|
+
axes: [
|
|
7055
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
|
|
7056
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
|
|
7057
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
|
|
7058
|
+
],
|
|
7059
|
+
dataShape: 'Array<{label, value}> or multi-series Array<{name, values:Array<{label,value}>}> — stem-and-dot alternative to bars',
|
|
7060
|
+
streamShape: 'flat',
|
|
7061
|
+
},
|
|
7062
|
+
{
|
|
7063
|
+
key: 'marimekko', label: 'Marimekko', method: 'marimekkoChart', category: '2d',
|
|
7064
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
7065
|
+
seriesCapable: true,
|
|
7066
|
+
axes: [
|
|
7067
|
+
{ role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.SHAPE_WIDTH, required: true },
|
|
7068
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_HEIGHT, required: true },
|
|
7069
|
+
{ role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: true },
|
|
7070
|
+
],
|
|
7071
|
+
dataShape: 'Multi-series Array<{name, values:Array<{label,value}>}> or Array<{label, values:number[]}> — variable-width 100%-stacked mosaic columns',
|
|
7072
|
+
streamShape: 'series',
|
|
7073
|
+
},
|
|
7074
|
+
{
|
|
7075
|
+
key: 'icicle', label: 'Icicle', method: 'icicleChart', category: '2d',
|
|
7076
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
7077
|
+
axes: [
|
|
7078
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.ANGLE_OR_AREA, required: true },
|
|
7079
|
+
{ role: 'group', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
7080
|
+
],
|
|
7081
|
+
dataShape: 'Array<{label, value?, children?}> — rectangular hierarchical partition chart (top-down bands by depth, width proportional to value)',
|
|
7082
|
+
streamShape: 'flat',
|
|
7083
|
+
},
|
|
7037
7084
|
{
|
|
7038
7085
|
key: 'bar3DOrbit', label: 'Bar (3D Orbit)', method: 'barChart3DOrbit', category: '3d',
|
|
7039
7086
|
coordSystem: COORD_SYSTEM.ORBIT_3D, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ORBIT,
|
|
@@ -16373,8 +16420,10 @@
|
|
|
16373
16420
|
const box = ChartBox.fromRenderer(this.renderer);
|
|
16374
16421
|
const {
|
|
16375
16422
|
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
16376
|
-
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
16423
|
+
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
16424
|
+
normalize = false, stackMode, legend: showLegend = false,
|
|
16377
16425
|
} = options;
|
|
16426
|
+
const isPercent = Boolean(stacked && (normalize || stackMode === 'percent'));
|
|
16378
16427
|
const plotBox = { x, y, width, height };
|
|
16379
16428
|
const isMultiline = Array.isArray(data[0]?.values);
|
|
16380
16429
|
const series = isMultiline ? data : [{ name: 'default', values: data }];
|
|
@@ -16383,6 +16432,10 @@
|
|
|
16383
16432
|
const palette = new ColorPalette(colors);
|
|
16384
16433
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
16385
16434
|
|
|
16435
|
+
const pointTotals = isPercent
|
|
16436
|
+
? Array.from({ length: pointCount }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s.values[i]?.value ?? 0), 0))
|
|
16437
|
+
: null;
|
|
16438
|
+
|
|
16386
16439
|
// Precompute each series' [base, top] band per point — base is 0
|
|
16387
16440
|
// unless stacked, in which case it's the running cumulative of prior
|
|
16388
16441
|
// series (computed once here, not re-derived during render).
|
|
@@ -16390,12 +16443,15 @@
|
|
|
16390
16443
|
const bands = series.map(s => {
|
|
16391
16444
|
const band = s.values.map((d, i) => {
|
|
16392
16445
|
const base = stacked ? running[i] : 0;
|
|
16393
|
-
|
|
16446
|
+
const val = isPercent
|
|
16447
|
+
? (pointTotals[i] > 0 ? (Math.max(0, d.value) / pointTotals[i]) * 100 : 0)
|
|
16448
|
+
: d.value;
|
|
16449
|
+
return { base, top: base + val, pct: val };
|
|
16394
16450
|
});
|
|
16395
16451
|
if (stacked) running = band.map(b => b.top);
|
|
16396
16452
|
return band;
|
|
16397
16453
|
});
|
|
16398
|
-
const maxValue = Math.max(...bands.flat().map(b => b.top)) || 1;
|
|
16454
|
+
const maxValue = isPercent ? 100 : (Math.max(...bands.flat().map(b => b.top)) || 1);
|
|
16399
16455
|
|
|
16400
16456
|
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
16401
16457
|
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
@@ -16410,6 +16466,14 @@
|
|
|
16410
16466
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
16411
16467
|
this.renderer.path(areaPath, `rgba(${r},${g},${bl},0.35)`, null, { tooltip: s.name });
|
|
16412
16468
|
this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
|
|
16469
|
+
if (typeof this.renderer.circle === 'function') {
|
|
16470
|
+
topPts.forEach((p, i) => {
|
|
16471
|
+
const d = s.values[i];
|
|
16472
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
16473
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
16474
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
16475
|
+
});
|
|
16476
|
+
}
|
|
16413
16477
|
|
|
16414
16478
|
if (seriesIndex === 0) {
|
|
16415
16479
|
const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
|
|
@@ -16574,8 +16638,11 @@
|
|
|
16574
16638
|
this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
|
|
16575
16639
|
|
|
16576
16640
|
pts.forEach(p => {
|
|
16577
|
-
this.renderer.circle
|
|
16578
|
-
|
|
16641
|
+
if (typeof this.renderer.circle === 'function') {
|
|
16642
|
+
this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
|
|
16643
|
+
{ tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
|
|
16644
|
+
{ label: p.d.label, series: isMultiline ? s.name : null });
|
|
16645
|
+
}
|
|
16579
16646
|
});
|
|
16580
16647
|
|
|
16581
16648
|
if (seriesIndex === 0) {
|
|
@@ -16594,35 +16661,104 @@
|
|
|
16594
16661
|
|
|
16595
16662
|
/**
|
|
16596
16663
|
* StackedBarChart — vertical stacked bar chart.
|
|
16664
|
+
*
|
|
16665
|
+
* Accepts either:
|
|
16666
|
+
* - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (the same
|
|
16667
|
+
* shape as `GroupedBarChart`, `LineChart` and `StackedAreaChart`), or
|
|
16668
|
+
* - Flat per-category stacks `Array<{label: string, values: number[]}>` with optional
|
|
16669
|
+
* `options.seriesLabels`.
|
|
16670
|
+
*
|
|
16671
|
+
* Draws a numeric Y axis (`_drawYAxis`), optional series legend (`legend: true`), and records each
|
|
16672
|
+
* segment with `this._datum()` so `onDatumClick` / `PivotChart` drilling works on every segment.
|
|
16597
16673
|
*/
|
|
16598
16674
|
class StackedBarChart extends BarChartBase {
|
|
16599
16675
|
static chartType = 'stackedBar';
|
|
16600
16676
|
|
|
16677
|
+
static _normalize(data, seriesLabels = null) {
|
|
16678
|
+
if (!Array.isArray(data) || data.length === 0) return { categories: [], seriesNames: [] };
|
|
16679
|
+
if (Array.isArray(data[0]?.values) && typeof data[0].values[0] === 'object' && data[0].values[0] !== null) {
|
|
16680
|
+
const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
|
|
16681
|
+
const maps = data.map(s => new Map(s.values.map(v => [v.label, v.value])));
|
|
16682
|
+
return {
|
|
16683
|
+
seriesNames: data.map((s, i) => s.name ?? seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
16684
|
+
categories: labels.map(label => ({ label, values: maps.map(m => m.get(label) ?? 0) })),
|
|
16685
|
+
};
|
|
16686
|
+
}
|
|
16687
|
+
const maxLen = Math.max(0, ...data.map(d => d.values?.length ?? 0));
|
|
16688
|
+
return {
|
|
16689
|
+
seriesNames: Array.from({ length: maxLen }, (_, i) => seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
16690
|
+
categories: data.map(d => ({ label: d.label, values: (d.values ?? []).map(v => v ?? 0) })),
|
|
16691
|
+
};
|
|
16692
|
+
}
|
|
16693
|
+
|
|
16694
|
+
_setup() {
|
|
16695
|
+
this._normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
|
|
16696
|
+
super._setup();
|
|
16697
|
+
if (this.options.legend && this.options.width === undefined && this._normalized.seriesNames.length > 0) {
|
|
16698
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
16699
|
+
}
|
|
16700
|
+
}
|
|
16701
|
+
|
|
16702
|
+
_legendOptions() {
|
|
16703
|
+
return {
|
|
16704
|
+
items: this._normalized.seriesNames.map((name, i) => ({ label: name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
16705
|
+
itemHeight: 20, markerSize: 10,
|
|
16706
|
+
};
|
|
16707
|
+
}
|
|
16708
|
+
|
|
16709
|
+
_isNormalized() {
|
|
16710
|
+
return Boolean(this.options.normalize || this.options.stackMode === 'percent');
|
|
16711
|
+
}
|
|
16712
|
+
|
|
16601
16713
|
_computeMaxValue() {
|
|
16602
|
-
|
|
16603
|
-
|
|
16714
|
+
if (this._isNormalized()) {
|
|
16715
|
+
return this._drawYAxis(100);
|
|
16716
|
+
}
|
|
16717
|
+
const { categories } = this._normalized;
|
|
16718
|
+
const rawMax = Math.max(0, ...categories.map(d => d.values.reduce((sum, val) => sum + Math.max(0, val), 0))) || 1;
|
|
16719
|
+
return this._drawYAxis(rawMax);
|
|
16604
16720
|
}
|
|
16605
16721
|
|
|
16606
16722
|
_renderBars(maxStackValue) {
|
|
16607
|
-
const
|
|
16723
|
+
const { categories, seriesNames } = this._normalized;
|
|
16724
|
+
if (categories.length === 0) return;
|
|
16608
16725
|
const { x, y, width, height } = this._plotBox;
|
|
16609
|
-
const
|
|
16610
|
-
const
|
|
16726
|
+
const normalize = this._isNormalized();
|
|
16727
|
+
const labels = categories.map(d => d.label);
|
|
16728
|
+
const { bandWidth, margin } = this._bandScale(categories.length);
|
|
16611
16729
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
16730
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
16731
|
+
const room = (bandWidth + margin) - 4;
|
|
16732
|
+
const cut = room >= measure('x'.repeat(6));
|
|
16733
|
+
const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
|
|
16612
16734
|
|
|
16613
|
-
|
|
16735
|
+
categories.forEach((d, i) => {
|
|
16614
16736
|
let currentY = y + height;
|
|
16615
16737
|
const barX = x + i * (bandWidth + margin);
|
|
16738
|
+
const colTotal = d.values.reduce((sum, val) => sum + Math.max(0, val ?? 0), 0);
|
|
16616
16739
|
d.values.forEach((value, j) => {
|
|
16617
|
-
|
|
16740
|
+
if (value == null || value <= 0) return;
|
|
16741
|
+
const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
|
|
16742
|
+
const barHeight = (plotted / maxStackValue) * height;
|
|
16618
16743
|
currentY -= barHeight;
|
|
16619
|
-
|
|
16620
|
-
|
|
16621
|
-
|
|
16744
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
16745
|
+
const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
|
|
16746
|
+
this._datum(this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
|
|
16747
|
+
tooltip: `${d.label} - ${series}: ${tipVal}`,
|
|
16748
|
+
}), { label: d.label, series });
|
|
16622
16749
|
});
|
|
16623
|
-
if (i % labelStep === 0)
|
|
16750
|
+
if (i % labelStep === 0) {
|
|
16751
|
+
this.renderer.text(cut ? TableChart.fit(String(d.label), room, measure) : d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
16752
|
+
}
|
|
16624
16753
|
});
|
|
16625
16754
|
}
|
|
16755
|
+
|
|
16756
|
+
_afterBars() {
|
|
16757
|
+
const { x, y, width } = this._plotBox;
|
|
16758
|
+
if (this.options.legend && this._normalized.seriesNames.length > 0) {
|
|
16759
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
16760
|
+
}
|
|
16761
|
+
}
|
|
16626
16762
|
}
|
|
16627
16763
|
|
|
16628
16764
|
// Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
|
|
@@ -16958,8 +17094,8 @@
|
|
|
16958
17094
|
({ from, to } = referenceLine);
|
|
16959
17095
|
}
|
|
16960
17096
|
if (from && to) {
|
|
16961
|
-
const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1 });
|
|
16962
|
-
line
|
|
17097
|
+
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] });
|
|
17098
|
+
line?.setAttribute?.('stroke-dasharray', '4 3');
|
|
16963
17099
|
}
|
|
16964
17100
|
}
|
|
16965
17101
|
|
|
@@ -16967,7 +17103,10 @@
|
|
|
16967
17103
|
const cx = toX(d.x);
|
|
16968
17104
|
const cy = toY(d.y);
|
|
16969
17105
|
const tooltip = d.label ? `${d.label}: (${d.x}, ${d.y})` : `(${d.x}, ${d.y})`;
|
|
16970
|
-
|
|
17106
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
17107
|
+
this._datum(this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip }), {
|
|
17108
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
17109
|
+
});
|
|
16971
17110
|
|
|
16972
17111
|
const xLow = d.errorXMinus != null ? toX(d.x - d.errorXMinus)
|
|
16973
17112
|
: d.errorX != null ? toX(d.x - d.errorX) : null;
|
|
@@ -17070,7 +17209,10 @@
|
|
|
17070
17209
|
// 3D effect: draw a line to the "floor"
|
|
17071
17210
|
this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
|
|
17072
17211
|
}
|
|
17073
|
-
|
|
17212
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
17213
|
+
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})` }), {
|
|
17214
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
17215
|
+
});
|
|
17074
17216
|
});
|
|
17075
17217
|
|
|
17076
17218
|
// Category color legend
|
|
@@ -17249,9 +17391,9 @@
|
|
|
17249
17391
|
const barY = y + height - barHeight;
|
|
17250
17392
|
// No gap between bars (unlike barChart's categorical gaps) — bins
|
|
17251
17393
|
// are contiguous numeric ranges, not independent categories.
|
|
17252
|
-
this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
17394
|
+
this._datum(this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
17253
17395
|
tooltip: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}: ${bin.count}`,
|
|
17254
|
-
});
|
|
17396
|
+
}), { label: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}` });
|
|
17255
17397
|
if (i % Math.ceil(bins.length / 10 || 1) === 0) {
|
|
17256
17398
|
this.renderer.text(Axis.formatValue(bin.from), barX, y + height + 16, { textAnchor: 'start', fontSize: '9px' });
|
|
17257
17399
|
}
|
|
@@ -17353,9 +17495,9 @@
|
|
|
17353
17495
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
|
|
17354
17496
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
|
|
17355
17497
|
const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
|
|
17356
|
-
this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
17498
|
+
this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
17357
17499
|
tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
|
|
17358
|
-
});
|
|
17500
|
+
}), { label: d.label, series: d.label });
|
|
17359
17501
|
this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
|
|
17360
17502
|
s.outliers.forEach(v => {
|
|
17361
17503
|
this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
|
|
@@ -17417,7 +17559,7 @@
|
|
|
17417
17559
|
const topPath = 'M' + topPts.map(p => `${p.px},${p.py}`).join(' L');
|
|
17418
17560
|
const areaPath = topPath + ` L${x + width},${y + height} L${x},${y + height} Z`;
|
|
17419
17561
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
17420
|
-
this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label });
|
|
17562
|
+
this._datum(this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label }), { label: d.label, series: d.label });
|
|
17421
17563
|
this.renderer.path(topPath, 'none', { color, width: 2 });
|
|
17422
17564
|
});
|
|
17423
17565
|
|
|
@@ -17455,9 +17597,9 @@
|
|
|
17455
17597
|
const leftPts = curve.map(p => ({ px: cx - (p.density / maxDensity) * halfWidth, py: this._toY(p.x) }));
|
|
17456
17598
|
const path = 'M' + rightPts.map(p => `${p.px},${p.py}`).join(' L')
|
|
17457
17599
|
+ ' L' + [...leftPts].reverse().map(p => `${p.px},${p.py}`).join(' L') + ' Z';
|
|
17458
|
-
this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
17600
|
+
this._datum(this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
17459
17601
|
tooltip: `${d.label}: n=${d.values.length}`,
|
|
17460
|
-
});
|
|
17602
|
+
}), { label: d.label, series: d.label });
|
|
17461
17603
|
|
|
17462
17604
|
const median = Statistics.quantile([...d.values].sort((a, b) => a - b), 0.5);
|
|
17463
17605
|
this.renderer.path(`M${cx - halfWidth},${this._toY(median)} L${cx + halfWidth},${this._toY(median)}`, 'none', { color: '#fff', width: 2 });
|
|
@@ -17712,6 +17854,34 @@
|
|
|
17712
17854
|
return result;
|
|
17713
17855
|
}
|
|
17714
17856
|
|
|
17857
|
+
/**
|
|
17858
|
+
* Recursive rectangular partition (icicle / flame graph) layout over a node tree.
|
|
17859
|
+
* Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
|
|
17860
|
+
* @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
|
|
17861
|
+
*/
|
|
17862
|
+
static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
|
|
17863
|
+
const items = (nodes ?? [])
|
|
17864
|
+
.map(node => ({ node, value: Hierarchy.value(node) }))
|
|
17865
|
+
.filter(it => it.value > 0);
|
|
17866
|
+
const total = items.reduce((s, it) => s + it.value, 0) || 1;
|
|
17867
|
+
|
|
17868
|
+
const result = [];
|
|
17869
|
+
let cursorX = x;
|
|
17870
|
+
for (const it of items) {
|
|
17871
|
+
const { node } = it;
|
|
17872
|
+
const spanW = (it.value / total) * width;
|
|
17873
|
+
const isLeaf = !node.children?.length;
|
|
17874
|
+
const topAncestor = topAncestors ? topAncestors.get(node) : node;
|
|
17875
|
+
result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
|
|
17876
|
+
if (!isLeaf) {
|
|
17877
|
+
const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
|
|
17878
|
+
result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
|
|
17879
|
+
}
|
|
17880
|
+
cursorX += spanW;
|
|
17881
|
+
}
|
|
17882
|
+
return result;
|
|
17883
|
+
}
|
|
17884
|
+
|
|
17715
17885
|
/**
|
|
17716
17886
|
* Flat, pre-order list of *visible* rows for an outline view. A branch
|
|
17717
17887
|
* present in `collapsed` contributes its own row but none of its
|
|
@@ -17801,9 +17971,9 @@
|
|
|
17801
17971
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
17802
17972
|
|
|
17803
17973
|
if (isLeaf) {
|
|
17804
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
17974
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
17805
17975
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
17806
|
-
});
|
|
17976
|
+
}), { label: node.label });
|
|
17807
17977
|
if (rw > 30 && rh > 14) {
|
|
17808
17978
|
this.renderer.text(node.label, rx + rw / 2, ry + rh / 2, {
|
|
17809
17979
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
@@ -17812,9 +17982,9 @@
|
|
|
17812
17982
|
} else {
|
|
17813
17983
|
const headerH = Math.min(rh, TreemapChart.HEADER_H);
|
|
17814
17984
|
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), 'rgba(0,0,0,0.03)', { color: '#ddd', width: 1 });
|
|
17815
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
17985
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
17816
17986
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
17817
|
-
});
|
|
17987
|
+
}), { label: node.label });
|
|
17818
17988
|
if (rw > 30) {
|
|
17819
17989
|
this.renderer.text(node.label, rx + 4, ry + headerH / 2, {
|
|
17820
17990
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: textColor, 'pointer-events': 'none',
|
|
@@ -17862,9 +18032,9 @@
|
|
|
17862
18032
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
17863
18033
|
|
|
17864
18034
|
const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
|
|
17865
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
18035
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
17866
18036
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
17867
|
-
});
|
|
18037
|
+
}), { label: node.label });
|
|
17868
18038
|
|
|
17869
18039
|
if (endAngle - startAngle > 0.35) {
|
|
17870
18040
|
const midAngle = (startAngle + endAngle) / 2;
|
|
@@ -17914,15 +18084,16 @@
|
|
|
17914
18084
|
const accentColor = this._colorForNode(topAncestor, depth);
|
|
17915
18085
|
|
|
17916
18086
|
const g = this.renderer.group();
|
|
17917
|
-
|
|
18087
|
+
const addChild = (child) => g?.appendChild?.(child);
|
|
18088
|
+
addChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
|
|
17918
18089
|
|
|
17919
18090
|
let labelX = indentX + 16;
|
|
17920
18091
|
if (hasChildren) {
|
|
17921
18092
|
const collapsedHere = this.collapsed ? this.collapsed.has(node) : false;
|
|
17922
18093
|
const badgeX = indentX + 26;
|
|
17923
18094
|
const badge = this.renderer.circle(badgeX, rowCenterY, 7, '#ffffff', { color: '#888', width: 1.2 });
|
|
17924
|
-
|
|
17925
|
-
|
|
18095
|
+
addChild(badge);
|
|
18096
|
+
addChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
|
|
17926
18097
|
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#555', 'pointer-events': 'none',
|
|
17927
18098
|
}));
|
|
17928
18099
|
badge.style.cursor = 'pointer';
|
|
@@ -17936,22 +18107,23 @@
|
|
|
17936
18107
|
labelX = badgeX + 16;
|
|
17937
18108
|
}
|
|
17938
18109
|
|
|
17939
|
-
|
|
18110
|
+
const labelEl = this._datum(this.renderer.text(node.label, labelX, rowCenterY, {
|
|
17940
18111
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#222',
|
|
17941
|
-
}));
|
|
18112
|
+
}), { label: node.label, series: null });
|
|
18113
|
+
addChild(labelEl);
|
|
17942
18114
|
|
|
17943
18115
|
if (hasChildren) {
|
|
17944
|
-
|
|
18116
|
+
addChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
|
|
17945
18117
|
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
17946
18118
|
}));
|
|
17947
18119
|
} else if (Number.isFinite(node.value)) {
|
|
17948
|
-
|
|
18120
|
+
addChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
|
|
17949
18121
|
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
17950
18122
|
}));
|
|
17951
18123
|
} else {
|
|
17952
18124
|
const extra = Hierarchy.outlineExtraText(node, structuralKeys);
|
|
17953
18125
|
if (extra) {
|
|
17954
|
-
|
|
18126
|
+
addChild(this.renderer.text(extra, detailX, rowCenterY, {
|
|
17955
18127
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
|
|
17956
18128
|
}));
|
|
17957
18129
|
}
|
|
@@ -17973,6 +18145,308 @@
|
|
|
17973
18145
|
OutlineChart.INDENT_W = 18;
|
|
17974
18146
|
OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
|
|
17975
18147
|
|
|
18148
|
+
/**
|
|
18149
|
+
* IcicleChart — rectangular hierarchical partition chart (top-down flame/icicle layout).
|
|
18150
|
+
* Each depth level forms a horizontal band and each node's width is proportional to its value
|
|
18151
|
+
* within its parent's span.
|
|
18152
|
+
*/
|
|
18153
|
+
class IcicleChart extends HierarchyChart {
|
|
18154
|
+
static chartType = 'icicle';
|
|
18155
|
+
|
|
18156
|
+
_render() {
|
|
18157
|
+
const data = this.data ?? [];
|
|
18158
|
+
const options = this.options;
|
|
18159
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
18160
|
+
const { x = box.x, y = box.y, width = box.width, height = box.height, title } = options;
|
|
18161
|
+
const plotBox = { x, y, width, height };
|
|
18162
|
+
|
|
18163
|
+
new ChartLabels({ title }).draw(this.renderer, plotBox);
|
|
18164
|
+
if (data.length === 0) return;
|
|
18165
|
+
|
|
18166
|
+
const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
|
|
18167
|
+
const bandHeight = height / maxDepth;
|
|
18168
|
+
const layout = Hierarchy.icicleLayout(data, x, y, width, bandHeight);
|
|
18169
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
18170
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '10px' }) ?? s.length * 10 * 0.55;
|
|
18171
|
+
|
|
18172
|
+
layout.forEach(entry => {
|
|
18173
|
+
const { node, x: rx, y: ry, width: rw, height: rh, depth, topAncestor } = entry;
|
|
18174
|
+
const color = this._colorForNode(topAncestor, depth);
|
|
18175
|
+
const textColor = this._textColorForNode(topAncestor, depth);
|
|
18176
|
+
|
|
18177
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
18178
|
+
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
18179
|
+
}), { label: node.label });
|
|
18180
|
+
|
|
18181
|
+
if (rw > 28 && rh > 14) {
|
|
18182
|
+
const fitted = TableChart.fit(String(node.label), rw - 6, measure);
|
|
18183
|
+
this.renderer.text(fitted, rx + rw / 2, ry + rh / 2, {
|
|
18184
|
+
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
18185
|
+
});
|
|
18186
|
+
}
|
|
18187
|
+
});
|
|
18188
|
+
}
|
|
18189
|
+
}
|
|
18190
|
+
|
|
18191
|
+
/**
|
|
18192
|
+
* ParetoChart — descending bar chart combined with a cumulative percentage line (0–100%)
|
|
18193
|
+
* on a right-hand axis and an 80% cutoff reference line.
|
|
18194
|
+
*/
|
|
18195
|
+
class ParetoChart extends BarChartBase {
|
|
18196
|
+
static chartType = 'pareto';
|
|
18197
|
+
|
|
18198
|
+
_setup() {
|
|
18199
|
+
super._setup();
|
|
18200
|
+
if (this.options.width === undefined) {
|
|
18201
|
+
this._plotBox.width = Math.max(80, this._plotBox.width - 36);
|
|
18202
|
+
}
|
|
18203
|
+
const items = (this.data ?? []).filter(d => d && typeof d.value === 'number' && d.value >= 0);
|
|
18204
|
+
this._sorted = this.options.sorted === false ? [...items] : [...items].sort((a, b) => b.value - a.value);
|
|
18205
|
+
}
|
|
18206
|
+
|
|
18207
|
+
_computeMaxValue() {
|
|
18208
|
+
const rawMax = Math.max(0, ...this._sorted.map(d => d.value)) || 1;
|
|
18209
|
+
return this._drawYAxis(rawMax, 0);
|
|
18210
|
+
}
|
|
18211
|
+
|
|
18212
|
+
_renderBars(maxValue) {
|
|
18213
|
+
const items = this._sorted;
|
|
18214
|
+
if (items.length === 0) return;
|
|
18215
|
+
const { x, y, width, height } = this._plotBox;
|
|
18216
|
+
const {
|
|
18217
|
+
barColor = '#3b82f6',
|
|
18218
|
+
lineColor = '#dc2626',
|
|
18219
|
+
cutoff = 80,
|
|
18220
|
+
colors,
|
|
18221
|
+
} = this.options;
|
|
18222
|
+
const { bandWidth, margin } = this._bandScale(items.length);
|
|
18223
|
+
const labelStep = Axis.categoryLabelStep(items.map(d => d.label), width);
|
|
18224
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
18225
|
+
const total = items.reduce((sum, d) => sum + d.value, 0) || 1;
|
|
18226
|
+
|
|
18227
|
+
// Right-hand percentage axis (0%..100%).
|
|
18228
|
+
const rightX = x + width;
|
|
18229
|
+
this.renderer.path(`M${rightX},${y} L${rightX},${y + height}`, 'none', { color: '#64748b', width: 1 });
|
|
18230
|
+
for (const pct of [0, 20, 40, 60, 80, 100]) {
|
|
18231
|
+
const py = y + height - (pct / 100) * height;
|
|
18232
|
+
this.renderer.path(`M${rightX},${py} L${rightX + 4},${py}`, 'none', { color: '#64748b', width: 1 });
|
|
18233
|
+
this.renderer.text(`${pct}%`, rightX + 8, py + 3, { textAnchor: 'start', fontSize: '10px', fill: '#475569' });
|
|
18234
|
+
}
|
|
18235
|
+
|
|
18236
|
+
// Dashed 80% cutoff reference line.
|
|
18237
|
+
if (cutoff > 0 && cutoff < 100) {
|
|
18238
|
+
const cutY = y + height - (cutoff / 100) * height;
|
|
18239
|
+
this.renderer.path(`M${x},${cutY} L${rightX},${cutY}`, 'none', {
|
|
18240
|
+
color: lineColor, width: 1, dashArray: '4,4',
|
|
18241
|
+
});
|
|
18242
|
+
}
|
|
18243
|
+
|
|
18244
|
+
let running = 0;
|
|
18245
|
+
const linePts = [];
|
|
18246
|
+
items.forEach((d, i) => {
|
|
18247
|
+
running += d.value;
|
|
18248
|
+
const cumPct = (running / total) * 100;
|
|
18249
|
+
const color = colors ? this._palette.colorAt(i) : barColor;
|
|
18250
|
+
const bar = this._barSpan(d.value);
|
|
18251
|
+
const barX = x + i * (bandWidth + margin);
|
|
18252
|
+
const cx = barX + bandWidth / 2;
|
|
18253
|
+
const py = y + height - (cumPct / 100) * height;
|
|
18254
|
+
linePts.push({ cx, py, label: d.label, value: d.value, cumPct });
|
|
18255
|
+
|
|
18256
|
+
this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, {
|
|
18257
|
+
bar: true,
|
|
18258
|
+
dataIndex: i,
|
|
18259
|
+
tooltip: `${d.label}: ${d.value} (${cumPct.toFixed(1)}% cumulative)`,
|
|
18260
|
+
}), { label: d.label });
|
|
18261
|
+
|
|
18262
|
+
if (i % labelStep === 0) {
|
|
18263
|
+
this.renderer.text(d.label, cx, y + height + 20, { textAnchor });
|
|
18264
|
+
}
|
|
18265
|
+
});
|
|
18266
|
+
|
|
18267
|
+
if (linePts.length > 0) {
|
|
18268
|
+
const dPath = 'M' + linePts.map(p => `${p.cx},${p.py}`).join(' L');
|
|
18269
|
+
this.renderer.path(dPath, 'none', { color: lineColor, width: 2 });
|
|
18270
|
+
if (typeof this.renderer.circle === 'function') {
|
|
18271
|
+
linePts.forEach(p => {
|
|
18272
|
+
this._datum(this.renderer.circle(p.cx, p.py, 3.5, lineColor, { color: '#fff', width: 1.5 }, {
|
|
18273
|
+
tooltip: `${p.label}: ${p.cumPct.toFixed(1)}% cumulative`,
|
|
18274
|
+
}), { label: p.label });
|
|
18275
|
+
});
|
|
18276
|
+
}
|
|
18277
|
+
}
|
|
18278
|
+
}
|
|
18279
|
+
}
|
|
18280
|
+
|
|
18281
|
+
/**
|
|
18282
|
+
* LollipopChart — stem-and-dot alternative to a bar chart for high-cardinality or multi-series
|
|
18283
|
+
* categorical comparisons with minimal ink density.
|
|
18284
|
+
*
|
|
18285
|
+
* Accepts either:
|
|
18286
|
+
* - 1D `Array<{label: string, value: number}>`, or
|
|
18287
|
+
* - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>`.
|
|
18288
|
+
*/
|
|
18289
|
+
class LollipopChart extends BarChartBase {
|
|
18290
|
+
static chartType = 'lollipop';
|
|
18291
|
+
|
|
18292
|
+
static _normalize(data) {
|
|
18293
|
+
if (!Array.isArray(data) || data.length === 0) return { isMulti: false, series: [], labels: [] };
|
|
18294
|
+
if (Array.isArray(data[0]?.values)) {
|
|
18295
|
+
const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
|
|
18296
|
+
return { isMulti: true, series: data, labels };
|
|
18297
|
+
}
|
|
18298
|
+
return {
|
|
18299
|
+
isMulti: false,
|
|
18300
|
+
series: [{ name: 'default', values: data }],
|
|
18301
|
+
labels: data.map(d => d.label),
|
|
18302
|
+
};
|
|
18303
|
+
}
|
|
18304
|
+
|
|
18305
|
+
_setup() {
|
|
18306
|
+
this._norm = LollipopChart._normalize(this.data);
|
|
18307
|
+
super._setup();
|
|
18308
|
+
const showLegend = this.options.legend ?? this._norm.isMulti;
|
|
18309
|
+
if (showLegend && this._norm.isMulti && this.options.width === undefined) {
|
|
18310
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
18311
|
+
}
|
|
18312
|
+
}
|
|
18313
|
+
|
|
18314
|
+
_legendOptions() {
|
|
18315
|
+
return {
|
|
18316
|
+
items: this._norm.series.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
18317
|
+
itemHeight: 20, markerSize: 10,
|
|
18318
|
+
};
|
|
18319
|
+
}
|
|
18320
|
+
|
|
18321
|
+
_computeMaxValue() {
|
|
18322
|
+
const allValues = this._norm.series.flatMap(s => s.values.map(v => v.value)).filter(v => typeof v === 'number');
|
|
18323
|
+
const rawMax = Math.max(0, ...allValues) || 1;
|
|
18324
|
+
const rawMin = Math.min(0, ...allValues);
|
|
18325
|
+
return this._drawYAxis(rawMax, rawMin);
|
|
18326
|
+
}
|
|
18327
|
+
|
|
18328
|
+
_renderBars() {
|
|
18329
|
+
const { isMulti, series, labels } = this._norm;
|
|
18330
|
+
if (labels.length === 0) return;
|
|
18331
|
+
const { x, y, width, height } = this._plotBox;
|
|
18332
|
+
const {
|
|
18333
|
+
color: singleColor = '#2563eb',
|
|
18334
|
+
colors,
|
|
18335
|
+
radius = 5,
|
|
18336
|
+
stemWidth = 2,
|
|
18337
|
+
} = this.options;
|
|
18338
|
+
const { bandWidth, margin } = this._bandScale(labels.length);
|
|
18339
|
+
const labelStep = Axis.categoryLabelStep(labels, width);
|
|
18340
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
18341
|
+
const zeroY = this._barSpan(0).end;
|
|
18342
|
+
const seriesCount = series.length;
|
|
18343
|
+
|
|
18344
|
+
labels.forEach((label, i) => {
|
|
18345
|
+
const bandX = x + i * (bandWidth + margin);
|
|
18346
|
+
series.forEach((s, sIdx) => {
|
|
18347
|
+
const pt = s.values.find(v => v.label === label);
|
|
18348
|
+
if (!pt || typeof pt.value !== 'number') return;
|
|
18349
|
+
const cx = seriesCount === 1
|
|
18350
|
+
? bandX + bandWidth / 2
|
|
18351
|
+
: bandX + ((sIdx + 0.5) / seriesCount) * bandWidth;
|
|
18352
|
+
const span = this._barSpan(pt.value);
|
|
18353
|
+
const col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
|
|
18354
|
+
const tip = isMulti ? `${s.name} - ${label}: ${pt.value}` : `${label}: ${pt.value}`;
|
|
18355
|
+
|
|
18356
|
+
this.renderer.path(`M${cx},${zeroY} L${cx},${span.end}`, 'none', { color: col, width: stemWidth });
|
|
18357
|
+
if (typeof this.renderer.circle === 'function') {
|
|
18358
|
+
this._datum(this.renderer.circle(cx, span.end, radius, col, { color: '#fff', width: 1.5 }, {
|
|
18359
|
+
tooltip: tip,
|
|
18360
|
+
}), { label, series: isMulti ? s.name : null });
|
|
18361
|
+
} else {
|
|
18362
|
+
this._datum(this.renderer.rect(cx - radius, span.end - radius, radius * 2, radius * 2, col, null, {
|
|
18363
|
+
tooltip: tip,
|
|
18364
|
+
}), { label, series: isMulti ? s.name : null });
|
|
18365
|
+
}
|
|
18366
|
+
});
|
|
18367
|
+
if (i % labelStep === 0) {
|
|
18368
|
+
this.renderer.text(label, bandX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
18369
|
+
}
|
|
18370
|
+
});
|
|
18371
|
+
}
|
|
18372
|
+
|
|
18373
|
+
_afterBars() {
|
|
18374
|
+
const showLegend = this.options.legend ?? this._norm.isMulti;
|
|
18375
|
+
if (showLegend && this._norm.isMulti) {
|
|
18376
|
+
const { x, y, width } = this._plotBox;
|
|
18377
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
18378
|
+
}
|
|
18379
|
+
}
|
|
18380
|
+
}
|
|
18381
|
+
|
|
18382
|
+
/**
|
|
18383
|
+
* MarimekkoChart — two-dimensional 100%-stacked mosaic chart where column width is proportional to
|
|
18384
|
+
* each category's total across series and segment height is each series' percentage share within
|
|
18385
|
+
* that category.
|
|
18386
|
+
*/
|
|
18387
|
+
class MarimekkoChart extends Chart {
|
|
18388
|
+
static chartType = 'marimekko';
|
|
18389
|
+
|
|
18390
|
+
_render() {
|
|
18391
|
+
const normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
|
|
18392
|
+
const { categories, seriesNames } = normalized;
|
|
18393
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
18394
|
+
const {
|
|
18395
|
+
x = box.x, y = box.y, width: rawWidth = box.width, height = box.height,
|
|
18396
|
+
colors, title, xAxisLabel, yAxisLabel, legend: showLegend = true,
|
|
18397
|
+
} = this.options;
|
|
18398
|
+
const palette = new ColorPalette(colors);
|
|
18399
|
+
const legendOpts = {
|
|
18400
|
+
items: seriesNames.map((name, i) => ({ label: name, color: palette.colorAt(i), dataIndex: i })),
|
|
18401
|
+
itemHeight: 20, markerSize: 10,
|
|
18402
|
+
};
|
|
18403
|
+
const width = showLegend && this.options.width === undefined && seriesNames.length > 0
|
|
18404
|
+
? Legend.plotWidthBeside({ x, y, width: rawWidth, height }, this.renderer.width, legendOpts)
|
|
18405
|
+
: rawWidth;
|
|
18406
|
+
const plotBox = { x, y, width, height };
|
|
18407
|
+
|
|
18408
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
18409
|
+
new Axis().drawYRange(this.renderer, plotBox, 0, 100);
|
|
18410
|
+
|
|
18411
|
+
const colTotals = categories.map(c => c.values.reduce((sum, v) => sum + Math.max(0, v ?? 0), 0));
|
|
18412
|
+
const grandTotal = colTotals.reduce((sum, v) => sum + v, 0) || 1;
|
|
18413
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
18414
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '11px' }) ?? s.length * 11 * 0.55;
|
|
18415
|
+
|
|
18416
|
+
let cursorX = x;
|
|
18417
|
+
categories.forEach((cat, i) => {
|
|
18418
|
+
const colTotal = colTotals[i];
|
|
18419
|
+
if (colTotal <= 0) return;
|
|
18420
|
+
const colW = (colTotal / grandTotal) * width;
|
|
18421
|
+
let cursorY = y + height;
|
|
18422
|
+
|
|
18423
|
+
cat.values.forEach((value, j) => {
|
|
18424
|
+
if (value == null || value <= 0) return;
|
|
18425
|
+
const share = (value / colTotal) * 100;
|
|
18426
|
+
const segH = (share / 100) * height;
|
|
18427
|
+
cursorY -= segH;
|
|
18428
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
18429
|
+
const color = palette.colorAt(j);
|
|
18430
|
+
this._datum(this.renderer.rect(cursorX, cursorY, colW, segH, color, { color: '#fff', width: 1 }, {
|
|
18431
|
+
tooltip: `${cat.label} - ${series}: ${value} (${share.toFixed(1)}%)`,
|
|
18432
|
+
}), { label: cat.label, series });
|
|
18433
|
+
});
|
|
18434
|
+
|
|
18435
|
+
if (colW >= 24) {
|
|
18436
|
+
const labelText = TableChart.fit(String(cat.label), colW - 4, measure);
|
|
18437
|
+
this.renderer.text(labelText, cursorX + colW / 2, y + height + 18, { textAnchor, fontSize: '11px' });
|
|
18438
|
+
}
|
|
18439
|
+
cursorX += colW;
|
|
18440
|
+
});
|
|
18441
|
+
|
|
18442
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
18443
|
+
|
|
18444
|
+
if (showLegend && seriesNames.length > 0) {
|
|
18445
|
+
Legend.beside({ x, y, width }, this.renderer.width, legendOpts).draw(this.renderer);
|
|
18446
|
+
}
|
|
18447
|
+
}
|
|
18448
|
+
}
|
|
18449
|
+
|
|
17976
18450
|
/**
|
|
17977
18451
|
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
17978
18452
|
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
@@ -18706,6 +19180,16 @@
|
|
|
18706
19180
|
class PivotView {
|
|
18707
19181
|
static ROLLUP_COLUMN = '_rollup';
|
|
18708
19182
|
|
|
19183
|
+
static AGGREGATIONS = [
|
|
19184
|
+
{ key: 'sum', label: 'Sum' },
|
|
19185
|
+
{ key: 'count', label: 'Count' },
|
|
19186
|
+
{ key: 'avg', label: 'Average' },
|
|
19187
|
+
{ key: 'median', label: 'Median' },
|
|
19188
|
+
{ key: 'min', label: 'Minimum' },
|
|
19189
|
+
{ key: 'max', label: 'Maximum' },
|
|
19190
|
+
{ key: 'stddev', label: 'Std Dev' },
|
|
19191
|
+
];
|
|
19192
|
+
|
|
18709
19193
|
/**
|
|
18710
19194
|
* @param {Array<object>} rows result rows: dimension values, measure values, and `_rollup`
|
|
18711
19195
|
* @param {object} [options]
|
|
@@ -18733,6 +19217,7 @@
|
|
|
18733
19217
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
18734
19218
|
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
18735
19219
|
* measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
|
|
19220
|
+
* @param {string[]} [options.visibleMeasures] subset of `measures` currently shown in the grid and chart
|
|
18736
19221
|
*/
|
|
18737
19222
|
constructor(rows, options = {}) {
|
|
18738
19223
|
this.onChange = options.onChange ?? null;
|
|
@@ -18748,6 +19233,18 @@
|
|
|
18748
19233
|
this.lightRules = options.trafficLights ?? null;
|
|
18749
19234
|
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
18750
19235
|
this.lights = this.lightRules ? 'rules' : null;
|
|
19236
|
+
/** Optional sort on the row axis: `{ columnKey, measure, direction: 'asc'|'desc' }`, or null. */
|
|
19237
|
+
this.rowSort = null;
|
|
19238
|
+
/** Optional Top-N / Bottom-N filter on row siblings: `{ count, columnKey, measure, direction: 'desc'|'asc' }`, or null. */
|
|
19239
|
+
this.topN = null;
|
|
19240
|
+
/** Post-rollup derived formula measures: `Map<name, { name, formula, params, fn }>`. */
|
|
19241
|
+
this.derivedMeasures = new Map();
|
|
19242
|
+
/** Summary / aggregation mode: `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`. */
|
|
19243
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === options.aggregation) ? options.aggregation : 'sum';
|
|
19244
|
+
/** Optional raw fact rows behind the cube, enabling exact cell-level Count, Average, Median, Min, Max, and Std Dev. */
|
|
19245
|
+
this.facts = Array.isArray(options.facts) ? options.facts : null;
|
|
19246
|
+
/** How cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
19247
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(options.valueMode) ? options.valueMode : 'value';
|
|
18751
19248
|
this._load(rows, options);
|
|
18752
19249
|
}
|
|
18753
19250
|
|
|
@@ -18764,6 +19261,10 @@
|
|
|
18764
19261
|
_load(rows, options) {
|
|
18765
19262
|
this._options = options;
|
|
18766
19263
|
this.rows = rows;
|
|
19264
|
+
if (Array.isArray(options.facts)) this.facts = options.facts;
|
|
19265
|
+
if (options.aggregation && PivotView.AGGREGATIONS.some(a => a.key === options.aggregation)) {
|
|
19266
|
+
this.aggregation = options.aggregation;
|
|
19267
|
+
}
|
|
18767
19268
|
this.rowAxis = [...(options.rowAxis ?? [])];
|
|
18768
19269
|
this.columnAxis = [...(options.columnAxis ?? [])];
|
|
18769
19270
|
this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
|
|
@@ -18772,9 +19273,42 @@
|
|
|
18772
19273
|
const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
|
|
18773
19274
|
this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
|
|
18774
19275
|
this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
|
|
18775
|
-
|
|
19276
|
+
if (Array.isArray(options.derivedMeasures)) {
|
|
19277
|
+
this.derivedMeasures.clear();
|
|
19278
|
+
for (const spec of options.derivedMeasures) {
|
|
19279
|
+
if (spec?.name && spec.formula) this.derivedMeasures.set(spec.name, PivotView._compileDerived(spec));
|
|
19280
|
+
}
|
|
19281
|
+
}
|
|
19282
|
+
const baseMeasures = options.measures ?? this._defaultMeasures(rows, options.columns);
|
|
19283
|
+
const derivedNames = [...this.derivedMeasures.keys()].filter(m => !baseMeasures.includes(m));
|
|
19284
|
+
this.allMeasures = [...baseMeasures, ...derivedNames];
|
|
19285
|
+
const keptMeasures = (options.visibleMeasures ?? this.measures ?? this.allMeasures).filter(m => this.allMeasures.includes(m));
|
|
19286
|
+
this.measures = keptMeasures.length > 0 ? keptMeasures : [...this.allMeasures];
|
|
18776
19287
|
this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
|
|
18777
19288
|
this._index();
|
|
19289
|
+
if (options.topN && options.topN.count > 0) {
|
|
19290
|
+
this.topN = {
|
|
19291
|
+
count: options.topN.count,
|
|
19292
|
+
columnKey: options.topN.columnKey ?? this.columnRoot.key,
|
|
19293
|
+
measure: options.topN.measure ?? this.measures[0],
|
|
19294
|
+
direction: options.topN.direction === 'asc' ? 'asc' : 'desc',
|
|
19295
|
+
};
|
|
19296
|
+
}
|
|
19297
|
+
}
|
|
19298
|
+
|
|
19299
|
+
static _compileDerived({ name, formula, params = null }) {
|
|
19300
|
+
if (typeof formula === 'function') {
|
|
19301
|
+
return { name, formula, params, fn: (row) => formula(row) };
|
|
19302
|
+
}
|
|
19303
|
+
const builtins = new Set(['abs', 'min', 'max', 'sqrt', 'log', 'exp', 'floor', 'ceil', 'round', 'clamp']);
|
|
19304
|
+
const paramList = Array.isArray(params) && params.length > 0
|
|
19305
|
+
? params
|
|
19306
|
+
: [...new Set((String(formula).match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter(id => !builtins.has(id)))];
|
|
19307
|
+
const compiled = compileFormula(String(formula), paramList);
|
|
19308
|
+
const fn = (row) => (paramList.some(p => typeof row[p] !== 'number')
|
|
19309
|
+
? null
|
|
19310
|
+
: compiled(...paramList.map(p => row[p])));
|
|
19311
|
+
return { name, formula, params: paramList, fn };
|
|
18778
19312
|
}
|
|
18779
19313
|
|
|
18780
19314
|
_defaultMeasures(rows, columns) {
|
|
@@ -18806,6 +19340,7 @@
|
|
|
18806
19340
|
|
|
18807
19341
|
_index() {
|
|
18808
19342
|
this.cells = new Map();
|
|
19343
|
+
this._sampleCache = new Map();
|
|
18809
19344
|
this.unplaced = 0;
|
|
18810
19345
|
this.rowRoot = PivotView._node([], null);
|
|
18811
19346
|
this.columnRoot = PivotView._node([], null);
|
|
@@ -18854,12 +19389,35 @@
|
|
|
18854
19389
|
|
|
18855
19390
|
/**
|
|
18856
19391
|
* The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
|
|
18857
|
-
* members below it, indented; the grand total last. `{ node, depth }`.
|
|
19392
|
+
* members below it, indented; the grand total last. `{ node, depth }`. When `rowSort` is set,
|
|
19393
|
+
* siblings at each level are ordered by that column/measure (nulls last).
|
|
18858
19394
|
*/
|
|
18859
19395
|
visibleRows() {
|
|
18860
19396
|
const out = [];
|
|
19397
|
+
const sortList = (list, spec) => {
|
|
19398
|
+
const { columnKey, measure, direction } = spec;
|
|
19399
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
19400
|
+
const colNode = this._findColumnNode(columnKey) ?? this.columnRoot;
|
|
19401
|
+
const col = { node: colNode, measure: this.measures.includes(measure) ? measure : this.measures[0] };
|
|
19402
|
+
return [...list].sort((a, b) => {
|
|
19403
|
+
const va = this.value(a, col), vb = this.value(b, col);
|
|
19404
|
+
if (va == null) return vb == null ? 0 : 1;
|
|
19405
|
+
if (vb == null) return -1;
|
|
19406
|
+
return sign * (va - vb);
|
|
19407
|
+
});
|
|
19408
|
+
};
|
|
19409
|
+
const order = (children) => {
|
|
19410
|
+
let list = [...children.values()];
|
|
19411
|
+
if (this.topN && this.topN.count > 0) {
|
|
19412
|
+
list = sortList(list, this.topN).slice(0, this.topN.count);
|
|
19413
|
+
}
|
|
19414
|
+
if (this.rowSort) {
|
|
19415
|
+
list = sortList(list, this.rowSort);
|
|
19416
|
+
}
|
|
19417
|
+
return list;
|
|
19418
|
+
};
|
|
18861
19419
|
const walk = (node) => {
|
|
18862
|
-
for (const child of node.children
|
|
19420
|
+
for (const child of order(node.children)) {
|
|
18863
19421
|
out.push({ node: child, depth: child.path.length - 1 });
|
|
18864
19422
|
if (!this.isCollapsed('rows', child)) walk(child);
|
|
18865
19423
|
}
|
|
@@ -18869,6 +19427,19 @@
|
|
|
18869
19427
|
return out;
|
|
18870
19428
|
}
|
|
18871
19429
|
|
|
19430
|
+
_findColumnNode(key) {
|
|
19431
|
+
if (!key || key === this.columnRoot.key) return this.columnRoot;
|
|
19432
|
+
const walk = (node) => {
|
|
19433
|
+
for (const child of node.children.values()) {
|
|
19434
|
+
if (child.key === key) return child;
|
|
19435
|
+
const found = walk(child);
|
|
19436
|
+
if (found) return found;
|
|
19437
|
+
}
|
|
19438
|
+
return null;
|
|
19439
|
+
};
|
|
19440
|
+
return walk(this.columnRoot);
|
|
19441
|
+
}
|
|
19442
|
+
|
|
18872
19443
|
/**
|
|
18873
19444
|
* The value columns drawn, left to right: each column-axis member's members (unless it is
|
|
18874
19445
|
* collapsed, or has none), then its own total; the grand total last. One per measure for each.
|
|
@@ -18936,10 +19507,140 @@
|
|
|
18936
19507
|
return out.map(({ id, ...cell }) => cell);
|
|
18937
19508
|
}
|
|
18938
19509
|
|
|
18939
|
-
/**
|
|
19510
|
+
/** Evaluates `measure` on a single data or fact row (handling derived formulas). */
|
|
19511
|
+
_rowMeasureValue(row, measure) {
|
|
19512
|
+
if (!row) return null;
|
|
19513
|
+
if (this.derivedMeasures.has(measure)) {
|
|
19514
|
+
let scope = row;
|
|
19515
|
+
if (this.derivedMeasures.size > 1) {
|
|
19516
|
+
scope = { ...row };
|
|
19517
|
+
for (const [name, d] of this.derivedMeasures) {
|
|
19518
|
+
const val = d.fn(scope);
|
|
19519
|
+
scope[name] = Number.isFinite(val) ? Math.round(val * 1000) / 1000 : null;
|
|
19520
|
+
if (name === measure) return scope[name];
|
|
19521
|
+
}
|
|
19522
|
+
}
|
|
19523
|
+
const d = this.derivedMeasures.get(measure);
|
|
19524
|
+
const v = d.fn(scope);
|
|
19525
|
+
return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
|
|
19526
|
+
}
|
|
19527
|
+
return row[measure] ?? null;
|
|
19528
|
+
}
|
|
19529
|
+
|
|
19530
|
+
/**
|
|
19531
|
+
* Collects the sample of numeric values behind `(rowNode, colNode, measure)` for statistical
|
|
19532
|
+
* aggregations (`count`, `avg`, `median`, `min`, `max`, `stddev`):
|
|
19533
|
+
* 1) From `this.facts` when raw fact rows are supplied,
|
|
19534
|
+
* 2) Else from `row._values?.[measure]` when pre-attached,
|
|
19535
|
+
* 3) Else from the descendant leaf cells in `this.cells`.
|
|
19536
|
+
*/
|
|
19537
|
+
_cellSample(rowNode, colNode, measure) {
|
|
19538
|
+
const cacheKey = `${rowNode.key}|${colNode.key}|${measure}`;
|
|
19539
|
+
if (this._sampleCache?.has(cacheKey)) return this._sampleCache.get(cacheKey);
|
|
19540
|
+
let values = [];
|
|
19541
|
+
const row = this.cell(rowNode, colNode);
|
|
19542
|
+
if (Array.isArray(this.facts) && this.facts.length > 0) {
|
|
19543
|
+
const matching = this.facts.filter(f => rowNode.path.every((v, i) => f[this.rowAxis[i]] === v)
|
|
19544
|
+
&& colNode.path.every((v, j) => f[this.columnAxis[j]] === v)
|
|
19545
|
+
&& this.pageAxis.every(p => p.value === null || String(f[p.dimension]) === String(p.value))
|
|
19546
|
+
&& this.memberFilters.every(m => (m.keep ? m.keep.includes(f[m.dimension]) : !m.remove?.includes(f[m.dimension]))));
|
|
19547
|
+
values = matching.map(f => this._rowMeasureValue(f, measure)).filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
19548
|
+
} else if (Array.isArray(row?._values?.[measure])) {
|
|
19549
|
+
values = row._values[measure].filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
19550
|
+
} else {
|
|
19551
|
+
const leaves = (node) => {
|
|
19552
|
+
if (node.children.size === 0) return [node];
|
|
19553
|
+
const out = [];
|
|
19554
|
+
for (const c of node.children.values()) out.push(...leaves(c));
|
|
19555
|
+
return out;
|
|
19556
|
+
};
|
|
19557
|
+
const rLeaves = leaves(rowNode);
|
|
19558
|
+
const cLeaves = leaves(colNode);
|
|
19559
|
+
for (const r of rLeaves) {
|
|
19560
|
+
for (const c of cLeaves) {
|
|
19561
|
+
const leafRow = this.cell(r, c);
|
|
19562
|
+
const v = this._rowMeasureValue(leafRow, measure);
|
|
19563
|
+
if (typeof v === 'number' && Number.isFinite(v)) values.push(v);
|
|
19564
|
+
}
|
|
19565
|
+
}
|
|
19566
|
+
}
|
|
19567
|
+
this._sampleCache?.set(cacheKey, values);
|
|
19568
|
+
return values;
|
|
19569
|
+
}
|
|
19570
|
+
|
|
19571
|
+
/** Computes `mode` (`'sum'|'count'|'avg'|'median'|'min'|'max'|'stddev'`) over `values`. */
|
|
19572
|
+
static aggregateValues(values, mode = 'sum', sumValue = null, countOverride = null) {
|
|
19573
|
+
if (mode === 'count') {
|
|
19574
|
+
if (typeof countOverride === 'number') return countOverride;
|
|
19575
|
+
return Array.isArray(values) ? values.length : 0;
|
|
19576
|
+
}
|
|
19577
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
19578
|
+
return mode === 'sum' ? sumValue : null;
|
|
19579
|
+
}
|
|
19580
|
+
const round3 = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null);
|
|
19581
|
+
switch (mode) {
|
|
19582
|
+
case 'sum':
|
|
19583
|
+
return sumValue !== null ? sumValue : round3(values.reduce((a, b) => a + b, 0));
|
|
19584
|
+
case 'avg': {
|
|
19585
|
+
if (typeof countOverride === 'number' && countOverride > 0 && typeof sumValue === 'number') {
|
|
19586
|
+
return round3(sumValue / countOverride);
|
|
19587
|
+
}
|
|
19588
|
+
return round3(values.reduce((a, b) => a + b, 0) / values.length);
|
|
19589
|
+
}
|
|
19590
|
+
case 'median': {
|
|
19591
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
19592
|
+
const mid = Math.floor(sorted.length / 2);
|
|
19593
|
+
const med = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
19594
|
+
return round3(med);
|
|
19595
|
+
}
|
|
19596
|
+
case 'min':
|
|
19597
|
+
return round3(Math.min(...values));
|
|
19598
|
+
case 'max':
|
|
19599
|
+
return round3(Math.max(...values));
|
|
19600
|
+
case 'stddev': {
|
|
19601
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
19602
|
+
const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length;
|
|
19603
|
+
return round3(Math.sqrt(variance));
|
|
19604
|
+
}
|
|
19605
|
+
default:
|
|
19606
|
+
return sumValue;
|
|
19607
|
+
}
|
|
19608
|
+
}
|
|
19609
|
+
|
|
19610
|
+
/** 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. */
|
|
18940
19611
|
value(rowNode, column) {
|
|
18941
19612
|
const row = this.cell(rowNode, column.node);
|
|
18942
|
-
|
|
19613
|
+
if (row === null) return null;
|
|
19614
|
+
const raw = this._rowMeasureValue(row, column.measure);
|
|
19615
|
+
if (!this.aggregation || this.aggregation === 'sum') return raw;
|
|
19616
|
+
const sample = this._cellSample(rowNode, column.node, column.measure);
|
|
19617
|
+
return PivotView.aggregateValues(sample, this.aggregation, raw, row._count);
|
|
19618
|
+
}
|
|
19619
|
+
|
|
19620
|
+
/**
|
|
19621
|
+
* The value at a row and column in the active `valueMode`: the raw value (`'value'`), or its share
|
|
19622
|
+
* (`0..100`) of the row total (`'rowPercent'`), column total (`'columnPercent'`), or grand total
|
|
19623
|
+
* (`'totalPercent'`). Returns null when the cell or its reference total is missing or zero.
|
|
19624
|
+
*/
|
|
19625
|
+
displayValue(rowNode, column) {
|
|
19626
|
+
const v = this.value(rowNode, column);
|
|
19627
|
+
if (this.valueMode === 'value' || typeof v !== 'number') return v;
|
|
19628
|
+
const base = this.valueMode === 'rowPercent'
|
|
19629
|
+
? this.value(rowNode, { node: this.columnRoot, measure: column.measure })
|
|
19630
|
+
: this.valueMode === 'columnPercent'
|
|
19631
|
+
? this.value(this.rowRoot, column)
|
|
19632
|
+
: this.value(this.rowRoot, { node: this.columnRoot, measure: column.measure });
|
|
19633
|
+
if (typeof base !== 'number' || base === 0) return null;
|
|
19634
|
+
return Math.round((v / base) * 1000) / 10;
|
|
19635
|
+
}
|
|
19636
|
+
|
|
19637
|
+
/** How a cell at `(rowNode, column)` is formatted for display in the grid. */
|
|
19638
|
+
cellText(rowNode, column, nullText = '—') {
|
|
19639
|
+
const row = this.cell(rowNode, column.node);
|
|
19640
|
+
if (row === null) return '';
|
|
19641
|
+
if (this.valueMode === 'value') return this.valueText(column.measure, this.value(rowNode, column), nullText);
|
|
19642
|
+
const pct = this.displayValue(rowNode, column);
|
|
19643
|
+
return pct == null ? nullText : `${pct.toFixed(1)}%`;
|
|
18943
19644
|
}
|
|
18944
19645
|
|
|
18945
19646
|
// ── Changes ───────────────────────────────────────────────────────────────
|
|
@@ -18961,14 +19662,135 @@
|
|
|
18961
19662
|
this._changed();
|
|
18962
19663
|
}
|
|
18963
19664
|
|
|
19665
|
+
/**
|
|
19666
|
+
* Expands an axis to `level` (1 = outermost dimension only, 2 = first two dimensions, etc.) by
|
|
19667
|
+
* expanding collapsible members above `level` and collapsing those at or below `level`.
|
|
19668
|
+
*/
|
|
19669
|
+
expandToLevel(axis, level) {
|
|
19670
|
+
const root = axis === 'rows' ? this.rowRoot : this.columnRoot;
|
|
19671
|
+
this.collapsed[axis].clear();
|
|
19672
|
+
const walk = (node) => {
|
|
19673
|
+
for (const c of node.children.values()) {
|
|
19674
|
+
if (c.path.length >= level && this.collapsible(c)) this.collapsed[axis].add(c.key);
|
|
19675
|
+
walk(c);
|
|
19676
|
+
}
|
|
19677
|
+
};
|
|
19678
|
+
walk(root);
|
|
19679
|
+
this._changed();
|
|
19680
|
+
}
|
|
19681
|
+
|
|
19682
|
+
/** Sets how cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
19683
|
+
setValueMode(mode) {
|
|
19684
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(mode) ? mode : 'value';
|
|
19685
|
+
this._changed();
|
|
19686
|
+
}
|
|
19687
|
+
|
|
19688
|
+
/**
|
|
19689
|
+
* Sets the summary / aggregation function for cube cells:
|
|
19690
|
+
* `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`.
|
|
19691
|
+
*/
|
|
19692
|
+
setAggregation(mode) {
|
|
19693
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === mode) ? mode : 'sum';
|
|
19694
|
+
this._changed();
|
|
19695
|
+
}
|
|
19696
|
+
|
|
18964
19697
|
/** Rows become columns and columns rows. */
|
|
18965
19698
|
swapAxes() {
|
|
18966
19699
|
[this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
|
|
18967
19700
|
this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
|
|
19701
|
+
this.rowSort = null;
|
|
19702
|
+
this.topN = null;
|
|
18968
19703
|
this._index();
|
|
18969
19704
|
this._changed();
|
|
18970
19705
|
}
|
|
18971
19706
|
|
|
19707
|
+
/**
|
|
19708
|
+
* Sorts row members within each level by `column` (`{ node?, columnKey?, measure }`) in `direction`
|
|
19709
|
+
* ('asc', 'desc', or null to clear).
|
|
19710
|
+
*/
|
|
19711
|
+
sortRowsBy(column, direction = 'desc') {
|
|
19712
|
+
if (!direction || !column) {
|
|
19713
|
+
this.rowSort = null;
|
|
19714
|
+
} else {
|
|
19715
|
+
const columnKey = column.columnKey ?? column.node?.key ?? this.columnRoot.key;
|
|
19716
|
+
const measure = column.measure ?? this.measures[0];
|
|
19717
|
+
this.rowSort = { columnKey, measure, direction };
|
|
19718
|
+
}
|
|
19719
|
+
this._changed();
|
|
19720
|
+
}
|
|
19721
|
+
|
|
19722
|
+
/** Clears any row sort, returning row members to their data order. */
|
|
19723
|
+
clearRowSort() {
|
|
19724
|
+
this.rowSort = null;
|
|
19725
|
+
this._changed();
|
|
19726
|
+
}
|
|
19727
|
+
|
|
19728
|
+
/**
|
|
19729
|
+
* Filters sibling row members at each level to the top (`direction = 'desc'`) or bottom
|
|
19730
|
+
* (`direction = 'asc'`) `count` members by `measure` / `column`. Pass `count <= 0` or `null` to clear.
|
|
19731
|
+
*/
|
|
19732
|
+
setTopN(count, { column = null, columnKey = null, measure = null, direction = 'desc' } = {}) {
|
|
19733
|
+
if (!count || count <= 0) {
|
|
19734
|
+
this.topN = null;
|
|
19735
|
+
} else {
|
|
19736
|
+
this.topN = {
|
|
19737
|
+
count: Math.floor(count),
|
|
19738
|
+
columnKey: columnKey ?? column?.columnKey ?? column?.node?.key ?? this.columnRoot.key,
|
|
19739
|
+
measure: measure ?? column?.measure ?? this.measures[0],
|
|
19740
|
+
direction: direction === 'asc' ? 'asc' : 'desc',
|
|
19741
|
+
};
|
|
19742
|
+
}
|
|
19743
|
+
this._changed();
|
|
19744
|
+
}
|
|
19745
|
+
|
|
19746
|
+
/** Clears any Top-N / Bottom-N row filter, showing all row members again. */
|
|
19747
|
+
clearTopN() {
|
|
19748
|
+
this.topN = null;
|
|
19749
|
+
this._changed();
|
|
19750
|
+
}
|
|
19751
|
+
|
|
19752
|
+
/**
|
|
19753
|
+
* Adds or updates a post-rollup derived formula measure (`{ name, formula, params? }`), evaluated at
|
|
19754
|
+
* every detail, subtotal, and grand-total cell via `compileFormula`.
|
|
19755
|
+
*/
|
|
19756
|
+
addDerivedMeasure({ name, formula, params = {} }) {
|
|
19757
|
+
if (!name || !formula) return;
|
|
19758
|
+
this.derivedMeasures.set(name, PivotView._compileDerived({ name, formula, params }));
|
|
19759
|
+
if (!this.allMeasures.includes(name)) this.allMeasures.push(name);
|
|
19760
|
+
if (!this.measures.includes(name)) this.measures.push(name);
|
|
19761
|
+
this._changed();
|
|
19762
|
+
}
|
|
19763
|
+
|
|
19764
|
+
/** Removes a derived formula measure by name. */
|
|
19765
|
+
removeDerivedMeasure(name) {
|
|
19766
|
+
if (!this.derivedMeasures.has(name)) return;
|
|
19767
|
+
this.derivedMeasures.delete(name);
|
|
19768
|
+
this.allMeasures = this.allMeasures.filter(m => m !== name);
|
|
19769
|
+
const next = this.measures.filter(m => m !== name);
|
|
19770
|
+
this.measures = next.length > 0 ? next : [...this.allMeasures];
|
|
19771
|
+
this._changed();
|
|
19772
|
+
}
|
|
19773
|
+
|
|
19774
|
+
/** Shows or hides a measure (`allMeasures` holds every available measure; at least one stays visible). */
|
|
19775
|
+
toggleMeasure(measure) {
|
|
19776
|
+
if (!this.allMeasures.includes(measure)) return;
|
|
19777
|
+
if (this.measures.includes(measure)) {
|
|
19778
|
+
if (this.measures.length <= 1) return;
|
|
19779
|
+
this.measures = this.measures.filter(m => m !== measure);
|
|
19780
|
+
} else {
|
|
19781
|
+
this.measures = this.allMeasures.filter(m => this.measures.includes(m) || m === measure);
|
|
19782
|
+
}
|
|
19783
|
+
this._changed();
|
|
19784
|
+
}
|
|
19785
|
+
|
|
19786
|
+
/** Sets the visible measures (kept in `allMeasures` order; ignored if none match). */
|
|
19787
|
+
setMeasures(measures) {
|
|
19788
|
+
const next = this.allMeasures.filter(m => measures.includes(m));
|
|
19789
|
+
if (next.length === 0) return;
|
|
19790
|
+
this.measures = next;
|
|
19791
|
+
this._changed();
|
|
19792
|
+
}
|
|
19793
|
+
|
|
18972
19794
|
_changed() {
|
|
18973
19795
|
this.onChange?.(this);
|
|
18974
19796
|
for (const listener of this._listeners) listener(this);
|
|
@@ -19151,22 +19973,83 @@
|
|
|
19151
19973
|
const names = columns.map(seriesName);
|
|
19152
19974
|
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
19153
19975
|
[category, rowLabels[r]],
|
|
19154
|
-
...columns.map((c, i) => [names[i], this.
|
|
19976
|
+
...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
|
|
19155
19977
|
]));
|
|
19156
19978
|
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
19157
19979
|
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
19158
19980
|
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
19159
19981
|
const measureOf = (c) => hint(c.measure).measure ?? null;
|
|
19982
|
+
const primaryMeasure = this.measures[0] ?? null;
|
|
19983
|
+
const totalCol = this.columnRoot.hasCells && primaryMeasure ? { node: this.columnRoot, measure: primaryMeasure } : null;
|
|
19984
|
+
const rowTotals = totalCol
|
|
19985
|
+
? rowNodes.map((node, r) => ({ label: rowLabels[r], value: this.displayValue(node, totalCol) })).filter(d => d.value != null)
|
|
19986
|
+
: null;
|
|
19160
19987
|
return {
|
|
19161
19988
|
rows,
|
|
19162
19989
|
columns: [
|
|
19163
19990
|
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
19164
19991
|
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
19165
19992
|
],
|
|
19166
|
-
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
19167
|
-
complete: this.unplaced === 0,
|
|
19993
|
+
// Members not broken down further cover the whole answer between them, unless rows went unplaced or Top-N filtered.
|
|
19994
|
+
complete: this.unplaced === 0 && !this.topN,
|
|
19168
19995
|
members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
|
|
19996
|
+
columnMembers: new Map(columns.map((c, i) => [names[i], c.node])),
|
|
19997
|
+
rowTotals,
|
|
19998
|
+
primaryMeasure: primaryMeasure ? { name: primaryMeasure, measure: measureOf({ measure: primaryMeasure }) } : null,
|
|
19999
|
+
hierarchy: this.hierarchyData(),
|
|
20000
|
+
};
|
|
20001
|
+
}
|
|
20002
|
+
|
|
20003
|
+
/**
|
|
20004
|
+
* The row-axis member tree shaped for hierarchical charts (`treemap`, `sunburst`, `outline`, `icicle`):
|
|
20005
|
+
* `Array<{ label, value?, children? }>`, using the single column/measure showing (or the row totals
|
|
20006
|
+
* column when multiple column members are showing) when the measure is additive and all values are
|
|
20007
|
+
* non-negative, or null when hierarchical part-of-whole does not fit.
|
|
20008
|
+
*/
|
|
20009
|
+
hierarchyData() {
|
|
20010
|
+
if (this.unplaced > 0 || this.topN || this.rowAxis.length < 1) return null;
|
|
20011
|
+
const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
|
|
20012
|
+
const col = columns.length === 1
|
|
20013
|
+
? columns[0]
|
|
20014
|
+
: (columns.length > 1 && this.columnRoot.hasCells && this.measures.length > 0
|
|
20015
|
+
? { node: this.columnRoot, measure: this.measures[0] }
|
|
20016
|
+
: null);
|
|
20017
|
+
if (!col) return null;
|
|
20018
|
+
const hint = (this._options.columns ?? []).find(c => c.name === col.measure) ?? {};
|
|
20019
|
+
if (hint.measure !== 'additive') return null;
|
|
20020
|
+
const allNodes = [];
|
|
20021
|
+
const collect = (node) => {
|
|
20022
|
+
for (const child of node.children.values()) {
|
|
20023
|
+
allNodes.push(child);
|
|
20024
|
+
if (!this.isCollapsed('rows', child)) collect(child);
|
|
20025
|
+
}
|
|
20026
|
+
};
|
|
20027
|
+
collect(this.rowRoot);
|
|
20028
|
+
if (allNodes.length === 0) return null;
|
|
20029
|
+
const labelMap = new Map(allNodes.map((n, i) => [n, PivotView._labels(allNodes)[i]]));
|
|
20030
|
+
let hasDeep = false;
|
|
20031
|
+
let negative = false;
|
|
20032
|
+
const build = (parent) => {
|
|
20033
|
+
const list = [];
|
|
20034
|
+
for (const child of parent.children.values()) {
|
|
20035
|
+
const label = labelMap.get(child) ?? PivotView.label(child);
|
|
20036
|
+
if (child.children.size > 0 && !this.isCollapsed('rows', child)) {
|
|
20037
|
+
const children = build(child);
|
|
20038
|
+
if (children.length > 0) {
|
|
20039
|
+
hasDeep = true;
|
|
20040
|
+
list.push({ label, children });
|
|
20041
|
+
}
|
|
20042
|
+
} else {
|
|
20043
|
+
const v = this.value(child, col);
|
|
20044
|
+
if (typeof v === 'number' && v < 0) negative = true;
|
|
20045
|
+
if (typeof v === 'number' && v > 0) list.push({ label, value: v });
|
|
20046
|
+
}
|
|
20047
|
+
}
|
|
20048
|
+
return list;
|
|
19169
20049
|
};
|
|
20050
|
+
const tree = build(this.rowRoot);
|
|
20051
|
+
if (negative || tree.length === 0 || (!hasDeep && this.rowAxis.length < 2)) return null;
|
|
20052
|
+
return { tree, members: new Map(allNodes.map(n => [labelMap.get(n) ?? PivotView.label(n), n])), measure: col.measure };
|
|
19170
20053
|
}
|
|
19171
20054
|
|
|
19172
20055
|
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
@@ -19179,19 +20062,33 @@
|
|
|
19179
20062
|
}
|
|
19180
20063
|
|
|
19181
20064
|
/**
|
|
19182
|
-
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the
|
|
19183
|
-
* finest level drills down (the host's, through onDrill).
|
|
20065
|
+
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the axis's
|
|
20066
|
+
* finest level drills down (the host's, through onDrill). `axis` defaults to `'rows'`, or if a
|
|
20067
|
+
* row label does not match and `series` is given, tries `'columns'`. Says which it did, or null.
|
|
19184
20068
|
*/
|
|
19185
|
-
drillFrom(label) {
|
|
19186
|
-
const
|
|
19187
|
-
|
|
19188
|
-
if (
|
|
19189
|
-
this.
|
|
19190
|
-
|
|
20069
|
+
drillFrom(label, series = null) {
|
|
20070
|
+
const data = this.chartData();
|
|
20071
|
+
const rowNode = data.members.get(label) ?? data.hierarchy?.members.get(label);
|
|
20072
|
+
if (rowNode && rowNode.path.length > 0) {
|
|
20073
|
+
if (this.isCollapsed('rows', rowNode)) {
|
|
20074
|
+
this.toggle('rows', rowNode);
|
|
20075
|
+
return 'expanded';
|
|
20076
|
+
}
|
|
20077
|
+
if (rowNode.path.length === this.rowAxis.length && this.onDrill) {
|
|
20078
|
+
this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: rowNode.path });
|
|
20079
|
+
return 'drilled';
|
|
20080
|
+
}
|
|
19191
20081
|
}
|
|
19192
|
-
|
|
19193
|
-
|
|
19194
|
-
|
|
20082
|
+
const colNode = series ? data.columnMembers.get(series) : data.columnMembers.get(label);
|
|
20083
|
+
if (colNode && colNode.path.length > 0) {
|
|
20084
|
+
if (this.isCollapsed('columns', colNode)) {
|
|
20085
|
+
this.toggle('columns', colNode);
|
|
20086
|
+
return 'expanded';
|
|
20087
|
+
}
|
|
20088
|
+
if (colNode.path.length === this.columnAxis.length && this.onDrill) {
|
|
20089
|
+
this.onDrill({ axis: 'columns', dimension: this.columnAxis.at(-1), direction: 'down', member: colNode.path });
|
|
20090
|
+
return 'drilled';
|
|
20091
|
+
}
|
|
19195
20092
|
}
|
|
19196
20093
|
return null;
|
|
19197
20094
|
}
|
|
@@ -19223,12 +20120,26 @@
|
|
|
19223
20120
|
}
|
|
19224
20121
|
}
|
|
19225
20122
|
items.push(...this._memberFilterItems(dimension, node));
|
|
20123
|
+
if (axis === 'columns') items.push(...this._columnSortItems(node));
|
|
19226
20124
|
items.push({ separator: true },
|
|
19227
20125
|
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
19228
20126
|
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
19229
20127
|
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
19230
20128
|
}
|
|
19231
20129
|
|
|
20130
|
+
_columnSortItems(node) {
|
|
20131
|
+
if (this.rowAxis.length === 0 || !node.hasCells) return [];
|
|
20132
|
+
const measure = this.measures[0];
|
|
20133
|
+
const active = this.rowSort?.columnKey === node.key && this.rowSort?.measure === measure ? this.rowSort.direction : null;
|
|
20134
|
+
const label = PivotView.label(node);
|
|
20135
|
+
return [
|
|
20136
|
+
{ separator: true },
|
|
20137
|
+
{ label: `Sort rows by ${label} descending${active === 'desc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'desc') },
|
|
20138
|
+
{ label: `Sort rows by ${label} ascending${active === 'asc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'asc') },
|
|
20139
|
+
...(this.rowSort ? [{ label: 'Clear row sort', action: () => this.clearRowSort() }] : []),
|
|
20140
|
+
];
|
|
20141
|
+
}
|
|
20142
|
+
|
|
19232
20143
|
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
19233
20144
|
|
|
19234
20145
|
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
@@ -19317,7 +20228,13 @@
|
|
|
19317
20228
|
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
19318
20229
|
*/
|
|
19319
20230
|
displayState() {
|
|
19320
|
-
|
|
20231
|
+
const state = { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
|
|
20232
|
+
if (this.rowSort) state.rowSort = { ...this.rowSort };
|
|
20233
|
+
if (this.topN) state.topN = { ...this.topN };
|
|
20234
|
+
if (this.allMeasures.length > 1 && this.measures.length < this.allMeasures.length) state.measures = [...this.measures];
|
|
20235
|
+
if (this.valueMode !== 'value') state.valueMode = this.valueMode;
|
|
20236
|
+
if (this.aggregation !== 'sum') state.aggregation = this.aggregation;
|
|
20237
|
+
return state;
|
|
19321
20238
|
}
|
|
19322
20239
|
|
|
19323
20240
|
/**
|
|
@@ -19328,6 +20245,16 @@
|
|
|
19328
20245
|
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
19329
20246
|
const lights = state.lights ?? null;
|
|
19330
20247
|
this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
|
|
20248
|
+
this.rowSort = state.rowSort && ['asc', 'desc'].includes(state.rowSort.direction) ? { ...state.rowSort } : null;
|
|
20249
|
+
this.topN = state.topN && state.topN.count > 0 && ['asc', 'desc'].includes(state.topN.direction) ? { ...state.topN } : null;
|
|
20250
|
+
if (Array.isArray(state.measures)) {
|
|
20251
|
+
const kept = this.allMeasures.filter(m => state.measures.includes(m));
|
|
20252
|
+
if (kept.length > 0) this.measures = kept;
|
|
20253
|
+
} else {
|
|
20254
|
+
this.measures = [...this.allMeasures];
|
|
20255
|
+
}
|
|
20256
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(state.valueMode) ? state.valueMode : 'value';
|
|
20257
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === state.aggregation) ? state.aggregation : 'sum';
|
|
19331
20258
|
this._changed();
|
|
19332
20259
|
}
|
|
19333
20260
|
|
|
@@ -19377,12 +20304,81 @@
|
|
|
19377
20304
|
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
19378
20305
|
}
|
|
19379
20306
|
|
|
19380
|
-
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
|
|
20307
|
+
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, measures, sort, copy the grid. */
|
|
19381
20308
|
gridMenu() {
|
|
19382
20309
|
const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
|
|
20310
|
+
const measureItems = this.allMeasures.length > 1 ? [{
|
|
20311
|
+
label: 'Measures',
|
|
20312
|
+
items: this.allMeasures.map(m => ({
|
|
20313
|
+
label: `${m}${this.measures.includes(m) ? ' ✓' : ''}`,
|
|
20314
|
+
disabled: this.measures.length === 1 && this.measures[0] === m,
|
|
20315
|
+
action: () => this.toggleMeasure(m),
|
|
20316
|
+
})),
|
|
20317
|
+
}] : [];
|
|
20318
|
+
const sortItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
20319
|
+
label: 'Sort rows',
|
|
20320
|
+
items: [
|
|
20321
|
+
...this.measures.flatMap(m => [
|
|
20322
|
+
{
|
|
20323
|
+
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' ? ' ✓' : ''}`,
|
|
20324
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'desc'),
|
|
20325
|
+
},
|
|
20326
|
+
{
|
|
20327
|
+
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' ? ' ✓' : ''}`,
|
|
20328
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'asc'),
|
|
20329
|
+
},
|
|
20330
|
+
]),
|
|
20331
|
+
...(this.rowSort ? [{ separator: true }, { label: 'Default order', action: () => this.clearRowSort() }] : []),
|
|
20332
|
+
],
|
|
20333
|
+
}] : [];
|
|
20334
|
+
const topNChoice = (count, direction, label) => {
|
|
20335
|
+
const m = this.measures[0];
|
|
20336
|
+
const active = this.topN?.count === count && this.topN?.direction === direction && this.topN?.measure === m;
|
|
20337
|
+
return {
|
|
20338
|
+
label: `${label}${active ? ' ✓' : ''}`,
|
|
20339
|
+
action: () => this.setTopN(count, { node: this.columnRoot, measure: m, direction }),
|
|
20340
|
+
};
|
|
20341
|
+
};
|
|
20342
|
+
const topNItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
20343
|
+
label: 'Top / Bottom N',
|
|
20344
|
+
items: [
|
|
20345
|
+
topNChoice(3, 'desc', 'Top 3'),
|
|
20346
|
+
topNChoice(5, 'desc', 'Top 5'),
|
|
20347
|
+
topNChoice(10, 'desc', 'Top 10'),
|
|
20348
|
+
{ separator: true },
|
|
20349
|
+
topNChoice(3, 'asc', 'Bottom 3'),
|
|
20350
|
+
topNChoice(5, 'asc', 'Bottom 5'),
|
|
20351
|
+
...(this.topN ? [{ separator: true }, { label: 'Show all rows', action: () => this.clearTopN() }] : []),
|
|
20352
|
+
],
|
|
20353
|
+
}] : [];
|
|
20354
|
+
const aggregationItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
20355
|
+
label: 'Summary (Count / Avg / Median)',
|
|
20356
|
+
items: PivotView.AGGREGATIONS.map(a => ({
|
|
20357
|
+
label: `${a.label}${this.aggregation === a.key ? ' ✓' : ''}`,
|
|
20358
|
+
action: () => this.setAggregation(a.key),
|
|
20359
|
+
})),
|
|
20360
|
+
}] : [];
|
|
20361
|
+
const valueModeChoice = (label, mode) => ({
|
|
20362
|
+
label: `${label}${this.valueMode === mode ? ' ✓' : ''}`,
|
|
20363
|
+
action: () => this.setValueMode(mode),
|
|
20364
|
+
});
|
|
20365
|
+
const valueModeItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
20366
|
+
label: 'Show values as',
|
|
20367
|
+
items: [
|
|
20368
|
+
valueModeChoice('Values', 'value'),
|
|
20369
|
+
...(this.columnAxis.length > 0 && this.columnRoot.hasCells ? [valueModeChoice('% of row total', 'rowPercent')] : []),
|
|
20370
|
+
...(this.rowAxis.length > 0 && this.rowRoot.hasCells ? [valueModeChoice('% of column total', 'columnPercent')] : []),
|
|
20371
|
+
...(this.rowRoot.hasCells && this.columnRoot.hasCells ? [valueModeChoice('% of grand total', 'totalPercent')] : []),
|
|
20372
|
+
],
|
|
20373
|
+
}] : [];
|
|
19383
20374
|
return [
|
|
19384
20375
|
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
19385
20376
|
{ label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
|
|
20377
|
+
...sortItems,
|
|
20378
|
+
...topNItems,
|
|
20379
|
+
...measureItems,
|
|
20380
|
+
...aggregationItems,
|
|
20381
|
+
...valueModeItems,
|
|
19386
20382
|
{
|
|
19387
20383
|
label: 'Traffic lights',
|
|
19388
20384
|
items: [
|
|
@@ -19524,7 +20520,7 @@
|
|
|
19524
20520
|
const lit = view.cellFill(node, c, cuts);
|
|
19525
20521
|
if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
|
|
19526
20522
|
const row = view.cell(node, c.node);
|
|
19527
|
-
const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
|
|
20523
|
+
const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
|
|
19528
20524
|
const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
|
|
19529
20525
|
// A cell with a value opens its menu (drill-through) where the host gives one.
|
|
19530
20526
|
if (row !== null && view.cellHasMenu()) {
|
|
@@ -19627,8 +20623,182 @@
|
|
|
19627
20623
|
|
|
19628
20624
|
/** The charts that fit what the grid shows, best first. The grid itself is the table. */
|
|
19629
20625
|
choices() {
|
|
19630
|
-
const
|
|
19631
|
-
|
|
20626
|
+
const chartData = this.view.chartData();
|
|
20627
|
+
const { rows, columns, complete } = chartData;
|
|
20628
|
+
const base = this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
|
|
20629
|
+
const seen = new Set(base.map(c => c.key));
|
|
20630
|
+
const extra = this._cubeChoices(chartData).filter(c => !seen.has(c.key) && this.chooser.registry.get(c.key));
|
|
20631
|
+
return [...base, ...extra];
|
|
20632
|
+
}
|
|
20633
|
+
|
|
20634
|
+
/**
|
|
20635
|
+
* Additional chart types suited to a cube's hierarchy, series, and matrix structure beyond the
|
|
20636
|
+
* general flat-table defaults in `ChartChooser`.
|
|
20637
|
+
*/
|
|
20638
|
+
_cubeChoices({ rows, columns, complete, hierarchy, rowTotals = null, primaryMeasure = null }) {
|
|
20639
|
+
if (!rows || rows.length === 0) return [];
|
|
20640
|
+
const categories = columns.filter(c => c.kind !== 'numeric');
|
|
20641
|
+
const numbers = columns.filter(c => c.kind === 'numeric');
|
|
20642
|
+
if (categories.length !== 1 || numbers.length === 0) return [];
|
|
20643
|
+
const category = categories[0];
|
|
20644
|
+
const add = (key, reason, data, options) => ({
|
|
20645
|
+
key,
|
|
20646
|
+
label: this.chooser.registry.get(key)?.label ?? key,
|
|
20647
|
+
reason,
|
|
20648
|
+
data,
|
|
20649
|
+
...(options ? { options } : {}),
|
|
20650
|
+
});
|
|
20651
|
+
const out = [];
|
|
20652
|
+
|
|
20653
|
+
if (numbers.length === 1) {
|
|
20654
|
+
const number = numbers[0];
|
|
20655
|
+
const points = rows.filter(r => r[number.name] != null).map(r => ({
|
|
20656
|
+
label: String(r[category.name]),
|
|
20657
|
+
value: r[number.name],
|
|
20658
|
+
}));
|
|
20659
|
+
if (points.length < 2) return out;
|
|
20660
|
+
this._addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, false);
|
|
20661
|
+
return out;
|
|
20662
|
+
}
|
|
20663
|
+
|
|
20664
|
+
// Multi-series (2+ numeric series from column-axis members or multiple measures).
|
|
20665
|
+
const gaps = rows.some(r => numbers.some(n => r[n.name] == null));
|
|
20666
|
+
if (gaps) return out;
|
|
20667
|
+
|
|
20668
|
+
const names = numbers.map(n => n.name).join(', ');
|
|
20669
|
+
const seriesData = numbers.map(n => ({
|
|
20670
|
+
name: n.name,
|
|
20671
|
+
values: rows.map(r => ({ label: String(r[category.name]), value: r[n.name] })),
|
|
20672
|
+
}));
|
|
20673
|
+
const allNonNeg = rows.every(r => numbers.every(n => r[n.name] >= 0));
|
|
20674
|
+
const additiveNonNeg = allNonNeg && numbers.every(n => n.measure !== 'semi-additive' && n.measure !== 'non-additive');
|
|
20675
|
+
|
|
20676
|
+
if (additiveNonNeg) {
|
|
20677
|
+
out.push(add('stackedBar', `${names} stacked for each ${category.name}`, seriesData));
|
|
20678
|
+
out.push(add('marimekko', `${names} share and total across ${category.name}, as a Marimekko mosaic`, seriesData));
|
|
20679
|
+
}
|
|
20680
|
+
out.push(add('lollipop', `${names} for each ${category.name}, as stems and dots`, seriesData));
|
|
20681
|
+
if (allNonNeg && rows.length >= 2) {
|
|
20682
|
+
out.push(add('area', `${names} across ${category.name}, filled`, seriesData));
|
|
20683
|
+
}
|
|
20684
|
+
if (additiveNonNeg && rows.length >= 2) {
|
|
20685
|
+
out.push(add('stackedArea', `${names} stacked across ${category.name}`, seriesData));
|
|
20686
|
+
out.push(add('streamGraph', `${names} flowing across ${category.name}`, seriesData));
|
|
20687
|
+
}
|
|
20688
|
+
if (allNonNeg && rows.length >= 2) {
|
|
20689
|
+
out.push(add('pipe', `${names} tapering across ${category.name}`, seriesData.map(s => ({
|
|
20690
|
+
name: s.name,
|
|
20691
|
+
values: s.values.map(v => ({ label: v.label, value: v.value, metric: v.value })),
|
|
20692
|
+
}))));
|
|
20693
|
+
}
|
|
20694
|
+
const heatData = rows.flatMap(r => numbers.map(n => ({
|
|
20695
|
+
x: String(r[category.name]),
|
|
20696
|
+
y: n.name,
|
|
20697
|
+
value: r[n.name],
|
|
20698
|
+
})));
|
|
20699
|
+
out.push(add('heatmap', `${names} for each ${category.name}, as a colour grid`, heatData));
|
|
20700
|
+
if (allNonNeg && rows.length >= 3 && rows.length <= 12) {
|
|
20701
|
+
out.push(add('radar', `${names} across ${category.name} axes`, seriesData.map(s => ({
|
|
20702
|
+
name: s.name,
|
|
20703
|
+
values: s.values.map(v => ({ axis: v.label, value: v.value })),
|
|
20704
|
+
}))));
|
|
20705
|
+
}
|
|
20706
|
+
if (rows.length >= 2) {
|
|
20707
|
+
out.push(add('parallelCoordinates', `${names} across parallel axes for each ${category.name}`,
|
|
20708
|
+
rows.map(r => ({
|
|
20709
|
+
label: String(r[category.name]),
|
|
20710
|
+
category: String(r[category.name]),
|
|
20711
|
+
...Object.fromEntries(numbers.map(n => [n.name, r[n.name]])),
|
|
20712
|
+
})),
|
|
20713
|
+
{ dimensions: numbers.map(n => n.name) }));
|
|
20714
|
+
if (allNonNeg) {
|
|
20715
|
+
out.push(add('populationPyramid', `${numbers[0].name} vs ${numbers[1].name} for each ${category.name}`,
|
|
20716
|
+
rows.map(r => ({ label: String(r[category.name]), left: r[numbers[0].name], right: r[numbers[1].name] })),
|
|
20717
|
+
{ leftLabel: numbers[0].name, rightLabel: numbers[1].name, showValues: true }));
|
|
20718
|
+
const flowLinks = rows.flatMap(r => numbers
|
|
20719
|
+
.filter(n => r[n.name] > 0)
|
|
20720
|
+
.map(n => ({ source: String(r[category.name]), target: n.name, value: r[n.name] })));
|
|
20721
|
+
if (flowLinks.length > 0) {
|
|
20722
|
+
out.push(add('sankey', `flow from ${category.name} to ${names}`, { links: flowLinks }));
|
|
20723
|
+
out.push(add('chord', `chords between ${category.name} and ${names}`, { links: flowLinks }));
|
|
20724
|
+
}
|
|
20725
|
+
}
|
|
20726
|
+
if (rows.length >= 3) {
|
|
20727
|
+
const distData = numbers.map(n => ({ label: n.name, values: rows.map(r => r[n.name]) }));
|
|
20728
|
+
out.push(add('boxPlot', `distribution of ${category.name} values for each of ${names}`, distData));
|
|
20729
|
+
out.push(add('density', `density curve of ${category.name} values for each of ${names}`, distData));
|
|
20730
|
+
out.push(add('violin', `violin distribution of ${category.name} values for each of ${names}`, distData));
|
|
20731
|
+
}
|
|
20732
|
+
out.push(add('scatter', `${numbers[0].name} against ${numbers[1].name} for each ${category.name}`,
|
|
20733
|
+
rows.map(r => ({ x: r[numbers[0].name], y: r[numbers[1].name], label: String(r[category.name]) }))));
|
|
20734
|
+
if (numbers.length >= 3 || (rowTotals && rowTotals.length === rows.length)) {
|
|
20735
|
+
out.push(add('bubble', `${numbers[0].name} and ${numbers[1].name} sized by ${numbers[2]?.name ?? (primaryMeasure?.name || 'Total')}`,
|
|
20736
|
+
rows.map((r, idx) => ({
|
|
20737
|
+
x: r[numbers[0].name],
|
|
20738
|
+
y: r[numbers[1].name],
|
|
20739
|
+
z: Math.max(1, numbers[2] ? r[numbers[2].name] : rowTotals[idx].value),
|
|
20740
|
+
label: String(r[category.name]),
|
|
20741
|
+
}))));
|
|
20742
|
+
}
|
|
20743
|
+
}
|
|
20744
|
+
if (allNonNeg) {
|
|
20745
|
+
out.push(add('bar3DOrbit', `${names} for each ${category.name}, in 3D`,
|
|
20746
|
+
rows.flatMap(r => numbers.map(n => ({ x: String(r[category.name]), z: n.name, value: r[n.name] })))));
|
|
20747
|
+
}
|
|
20748
|
+
if (additiveNonNeg) {
|
|
20749
|
+
out.push(add('stackedBar3DOrbit', `${names} stacked for each ${category.name}, in 3D`,
|
|
20750
|
+
rows.map(r => ({ x: String(r[category.name]), z: category.name, values: numbers.map(n => r[n.name]) })),
|
|
20751
|
+
{ seriesLabels: numbers.map(n => n.name) }));
|
|
20752
|
+
}
|
|
20753
|
+
if (rowTotals && rowTotals.length >= 2 && primaryMeasure) {
|
|
20754
|
+
this._addSingleSeriesChoices(out, add, category, primaryMeasure, rowTotals, complete, hierarchy, true);
|
|
20755
|
+
}
|
|
20756
|
+
return out;
|
|
20757
|
+
}
|
|
20758
|
+
|
|
20759
|
+
_addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, includeBase = false) {
|
|
20760
|
+
const nonNeg = points.every(d => d.value >= 0);
|
|
20761
|
+
if (includeBase) {
|
|
20762
|
+
out.push(add('bar', `total ${number.name} for each ${category.name}`, points));
|
|
20763
|
+
if (nonNeg && complete && number.measure === 'additive' && points.length <= 8) {
|
|
20764
|
+
out.push(add('pie', `share of total ${number.name} across ${category.name}`, points));
|
|
20765
|
+
out.push(add('donut', `share of total ${number.name} across ${category.name}, as a ring`, points));
|
|
20766
|
+
}
|
|
20767
|
+
}
|
|
20768
|
+
if (hierarchy) {
|
|
20769
|
+
out.push(add('treemap', `${number.name} across the ${category.name} hierarchy`, hierarchy.tree));
|
|
20770
|
+
out.push(add('sunburst', `${number.name} across the ${category.name} hierarchy, in rings`, hierarchy.tree));
|
|
20771
|
+
out.push(add('icicle', `${number.name} across the ${category.name} hierarchy, as an icicle partition`, hierarchy.tree));
|
|
20772
|
+
out.push(add('outline', `${number.name} across the ${category.name} hierarchy, as an outline`, hierarchy.tree));
|
|
20773
|
+
}
|
|
20774
|
+
if (number.measure === 'additive') {
|
|
20775
|
+
out.push(add('waterfall', `how each ${category.name} adds to ${number.name}`, points));
|
|
20776
|
+
if (nonNeg && complete) {
|
|
20777
|
+
out.push(add('pareto', `cumulative share of ${number.name} across ${category.name}`, points));
|
|
20778
|
+
}
|
|
20779
|
+
}
|
|
20780
|
+
if (!includeBase) {
|
|
20781
|
+
out.push(add('lollipop', `${number.name} for each ${category.name}, as stems and dots`, points));
|
|
20782
|
+
if (nonNeg && points.length >= 2) {
|
|
20783
|
+
out.push(add('pipe', `${number.name} tapering across ${category.name}`,
|
|
20784
|
+
points.map(d => ({ label: d.label, value: d.value, metric: d.value }))));
|
|
20785
|
+
}
|
|
20786
|
+
}
|
|
20787
|
+
if (nonNeg && points.length <= 12) {
|
|
20788
|
+
out.push(add('funnel', `${number.name} across ${category.name} stages`, points));
|
|
20789
|
+
out.push(add('radialBar', `${number.name} for each ${category.name}, around a circle`, points));
|
|
20790
|
+
if (!includeBase && points.length >= 3) {
|
|
20791
|
+
out.push(add('radar', `${number.name} across ${category.name} axes`, points.map(d => ({ axis: d.label, value: d.value }))));
|
|
20792
|
+
}
|
|
20793
|
+
out.push(add('wordCloud', `${category.name} sized by ${number.name}`, points.map(d => ({ text: d.label, value: d.value }))));
|
|
20794
|
+
out.push(add('bar3D', `${number.name} for each ${category.name}, in 3D`, points));
|
|
20795
|
+
}
|
|
20796
|
+
if (points.length >= 3) {
|
|
20797
|
+
const sample = points.map(d => d.value);
|
|
20798
|
+
out.push(add('histogram', `frequency distribution of ${number.name} across ${category.name}`, sample));
|
|
20799
|
+
out.push(add('qq', `normal Q-Q plot of ${number.name} across ${category.name}`, sample));
|
|
20800
|
+
out.push(add('lag', `lag-1 autocorrelation of ${number.name} across ${category.name}`, sample));
|
|
20801
|
+
}
|
|
19632
20802
|
}
|
|
19633
20803
|
|
|
19634
20804
|
/** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
|
|
@@ -19637,20 +20807,826 @@
|
|
|
19637
20807
|
return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
|
|
19638
20808
|
}
|
|
19639
20809
|
|
|
19640
|
-
/**
|
|
19641
|
-
|
|
20810
|
+
/** Sets the preferred chart type key (e.g. `'stackedBar'`, `'line'`, `'bar'`, `'pareto'`) and redraws if attached. */
|
|
20811
|
+
setType(type) {
|
|
20812
|
+
this.type = type;
|
|
20813
|
+
this.redraw?.();
|
|
20814
|
+
return this.chosen();
|
|
20815
|
+
}
|
|
20816
|
+
|
|
20817
|
+
/** The chart's menu: "Chart as" each chart that fits (plus quick top-level items), the one showing ticked. */
|
|
20818
|
+
menu(redraw = () => this.redraw?.()) {
|
|
19642
20819
|
const current = this.chosen()?.key;
|
|
19643
|
-
|
|
19644
|
-
|
|
19645
|
-
|
|
19646
|
-
|
|
19647
|
-
|
|
19648
|
-
|
|
19649
|
-
|
|
20820
|
+
const all = this.choices();
|
|
20821
|
+
const toItem = (c) => ({
|
|
20822
|
+
label: `${c.label}${c.key === current ? ' ✓' : ''}`,
|
|
20823
|
+
action: () => { this.type = c.key; redraw(); },
|
|
20824
|
+
});
|
|
20825
|
+
const quickKeys = new Set([
|
|
20826
|
+
'groupedBar', 'stackedBar', 'bar', 'line', 'area', 'stackedArea',
|
|
20827
|
+
'pareto', 'waterfall', 'marimekko', 'lollipop', 'heatmap', 'radar',
|
|
20828
|
+
'treemap', 'sunburst', 'icicle', 'sankey', 'pie', 'donut',
|
|
20829
|
+
]);
|
|
20830
|
+
const quickItems = all.filter(c => quickKeys.has(c.key)).map(toItem);
|
|
20831
|
+
return [
|
|
20832
|
+
{
|
|
20833
|
+
label: 'Chart as',
|
|
20834
|
+
items: all.map(toItem),
|
|
20835
|
+
},
|
|
20836
|
+
...(quickItems.length > 0 ? [{ separator: true }, ...quickItems] : []),
|
|
20837
|
+
];
|
|
20838
|
+
}
|
|
20839
|
+
|
|
20840
|
+
/** A click on a charted value: drill the grid at its member (or its series' column member). */
|
|
20841
|
+
click(datum) { return this.view.drillFrom(datum.label, datum.series ?? null); }
|
|
20842
|
+
}
|
|
20843
|
+
|
|
20844
|
+
/**
|
|
20845
|
+
* AskView — the state and next-word prediction model for an interactive "Ask" bar
|
|
20846
|
+
* (question input, scrollable predictive typeahead list with semantic sense/reference/relation
|
|
20847
|
+
* badges, keyboard navigation, and configurable next-word prediction), kept apart from DOM
|
|
20848
|
+
* rendering so all of it can be unit-tested under Node.js without a browser.
|
|
20849
|
+
*
|
|
20850
|
+
* A host can supply its own `suggest(text, view)` callback (for example, semanticengine's
|
|
20851
|
+
* `(text) => engine.suggest(text, { sampler: (dim) => engine.members(dim) })`), or pass
|
|
20852
|
+
* declarative vocabulary hints (`measures`, `dimensions`, `members`, `vocabulary`,
|
|
20853
|
+
* `sampleQuestions`) to use `AskView.nextWordPredictor(...)`.
|
|
20854
|
+
*/
|
|
20855
|
+
class AskView {
|
|
20856
|
+
/** Default choices for how many predicted next-word items are visible at once in the scrollable list (`0` = Off). */
|
|
20857
|
+
static DEFAULT_PREDICT_CHOICES = [0, 3, 5, 8, 10];
|
|
20858
|
+
|
|
20859
|
+
/**
|
|
20860
|
+
* @param {object} [options]
|
|
20861
|
+
* @param {string} [options.value=''] initial question text
|
|
20862
|
+
* @param {function} [options.suggest] `(text, view) => Array<string|{display, completion?, sense?, reference?, relation?, kind?}>`, or a promise of one
|
|
20863
|
+
* @param {string[]} [options.measures] measure names for built-in next-word prediction
|
|
20864
|
+
* @param {string[]} [options.dimensions] dimension names for built-in next-word prediction
|
|
20865
|
+
* @param {function|object} [options.members] `(dimension) => Array<string|number>` or `{ dim: [...] }` for value prediction after `=`
|
|
20866
|
+
* @param {Array<string|{display, completion?, sense?, reference?, relation?, kind?}>} [options.vocabulary] custom vocabulary entries
|
|
20867
|
+
* @param {string[]} [options.sampleQuestions] sample questions used both as clickable pills and to learn next-word transitions
|
|
20868
|
+
* @param {boolean} [options.predict=true] whether next-word prediction is initially enabled
|
|
20869
|
+
* @param {number} [options.visibleCount=5] how many items are visible at once in the scrollable suggestion viewport
|
|
20870
|
+
* @param {number[]} [options.predictChoices=[0, 3, 5, 8, 10]] configurable choices for the predict-next-word selector (`0` = Off)
|
|
20871
|
+
* @param {number} [options.maxSuggestions=50] maximum number of suggestions kept in the scrollable list
|
|
20872
|
+
* @param {function} [options.onAsk] `(question, view) => *` called when the user submits the question
|
|
20873
|
+
* @param {function} [options.onComplete] `(item, view) => void` called when a suggestion is accepted
|
|
20874
|
+
* @param {function} [options.onChange] `(view) => void` called whenever view state changes
|
|
20875
|
+
*/
|
|
20876
|
+
constructor(options = {}) {
|
|
20877
|
+
this.value = options.value ?? '';
|
|
20878
|
+
this.samples = [...(options.sampleQuestions ?? options.samples ?? [])];
|
|
20879
|
+
this.predictChoices = Array.isArray(options.predictChoices) && options.predictChoices.length > 0
|
|
20880
|
+
? [...options.predictChoices]
|
|
20881
|
+
: [...AskView.DEFAULT_PREDICT_CHOICES];
|
|
20882
|
+
const initialVisible = Number.isFinite(options.visibleCount) && options.visibleCount > 0 ? Math.floor(options.visibleCount) : 5;
|
|
20883
|
+
this.visibleCount = initialVisible;
|
|
20884
|
+
this.predict = options.predict !== false && options.visibleCount !== 0;
|
|
20885
|
+
if (this.predict && !this.predictChoices.includes(this.visibleCount)) {
|
|
20886
|
+
this.predictChoices = [...new Set([...this.predictChoices, this.visibleCount])].sort((a, b) => a - b);
|
|
20887
|
+
}
|
|
20888
|
+
this.maxSuggestions = Number.isFinite(options.maxSuggestions) && options.maxSuggestions > 0
|
|
20889
|
+
? Math.floor(options.maxSuggestions)
|
|
20890
|
+
: 50;
|
|
20891
|
+
this.suggestFn = typeof options.suggest === 'function'
|
|
20892
|
+
? options.suggest
|
|
20893
|
+
: AskView.nextWordPredictor({
|
|
20894
|
+
measures: options.measures,
|
|
20895
|
+
dimensions: options.dimensions,
|
|
20896
|
+
members: options.members,
|
|
20897
|
+
vocabulary: options.vocabulary,
|
|
20898
|
+
sampleQuestions: this.samples,
|
|
20899
|
+
});
|
|
20900
|
+
this.onAsk = options.onAsk ?? null;
|
|
20901
|
+
this.onComplete = options.onComplete ?? null;
|
|
20902
|
+
this.onChange = options.onChange ?? null;
|
|
20903
|
+
this.items = [];
|
|
20904
|
+
this.selectedIndex = -1;
|
|
20905
|
+
this.open = false;
|
|
20906
|
+
this.history = [];
|
|
20907
|
+
this._ticket = 0;
|
|
20908
|
+
}
|
|
20909
|
+
|
|
20910
|
+
/**
|
|
20911
|
+
* Current predict-next-word setting as a single number (`0` when Off, else `visibleCount`),
|
|
20912
|
+
* matching `predictChoices`.
|
|
20913
|
+
*/
|
|
20914
|
+
get predictCount() {
|
|
20915
|
+
return this.predict ? this.visibleCount : 0;
|
|
20916
|
+
}
|
|
20917
|
+
|
|
20918
|
+
/**
|
|
20919
|
+
* Enables or disables next-word prediction, or sets how many items show at once in the
|
|
20920
|
+
* scrollable list (`0` or `false` turns prediction off; a positive integer enables it with
|
|
20921
|
+
* that viewport size).
|
|
20922
|
+
*/
|
|
20923
|
+
setPredictCount(countOrEnabled) {
|
|
20924
|
+
if (countOrEnabled === false || countOrEnabled === 0 || countOrEnabled === '0' || countOrEnabled === 'off') {
|
|
20925
|
+
this.predict = false;
|
|
20926
|
+
this.items = [];
|
|
20927
|
+
this.selectedIndex = -1;
|
|
20928
|
+
this.open = false;
|
|
20929
|
+
this._changed();
|
|
20930
|
+
return;
|
|
20931
|
+
}
|
|
20932
|
+
if (countOrEnabled === true) {
|
|
20933
|
+
this.predict = true;
|
|
20934
|
+
if (this.visibleCount <= 0) this.visibleCount = 5;
|
|
20935
|
+
} else {
|
|
20936
|
+
const n = Number(countOrEnabled);
|
|
20937
|
+
if (Number.isFinite(n) && n > 0) {
|
|
20938
|
+
this.predict = true;
|
|
20939
|
+
this.visibleCount = Math.floor(n);
|
|
20940
|
+
}
|
|
20941
|
+
}
|
|
20942
|
+
this.refresh();
|
|
20943
|
+
}
|
|
20944
|
+
|
|
20945
|
+
/** Updates the input text and refreshes next-word predictions if enabled. */
|
|
20946
|
+
setInput(text, { suggest = true } = {}) {
|
|
20947
|
+
this.value = String(text ?? '');
|
|
20948
|
+
if (suggest && this.predict) {
|
|
20949
|
+
return this.refresh();
|
|
20950
|
+
} else {
|
|
20951
|
+
if (!suggest) {
|
|
20952
|
+
this.open = false;
|
|
20953
|
+
this.selectedIndex = -1;
|
|
20954
|
+
}
|
|
20955
|
+
this._changed();
|
|
20956
|
+
}
|
|
20957
|
+
}
|
|
20958
|
+
|
|
20959
|
+
/**
|
|
20960
|
+
* Swaps the source of suggestions (a host whose vocabulary changed — another model or
|
|
20961
|
+
* ontology) and refreshes the list.
|
|
20962
|
+
*/
|
|
20963
|
+
setSuggest(suggest) {
|
|
20964
|
+
this.suggestFn = typeof suggest === 'function' ? suggest : null;
|
|
20965
|
+
this.refresh();
|
|
20966
|
+
}
|
|
20967
|
+
|
|
20968
|
+
/**
|
|
20969
|
+
* Recomputes suggestions for the current `value` and opens the list if any match. Nothing is
|
|
20970
|
+
* selected until the user moves into the list, so Enter asks the question as typed.
|
|
20971
|
+
*
|
|
20972
|
+
* `suggest` may answer with a promise (an engine still loading, a server's vocabulary): the
|
|
20973
|
+
* list keeps what it shows until it settles, and an answer for text since changed is dropped.
|
|
20974
|
+
* Returns the items, or a promise of them.
|
|
20975
|
+
*/
|
|
20976
|
+
refresh() {
|
|
20977
|
+
const ticket = ++this._ticket;
|
|
20978
|
+
if (!this.predict || !this.suggestFn) {
|
|
20979
|
+
this._apply([]);
|
|
20980
|
+
return this.items;
|
|
20981
|
+
}
|
|
20982
|
+
let raw;
|
|
20983
|
+
try {
|
|
20984
|
+
raw = this.suggestFn(this.value, this);
|
|
20985
|
+
} catch {
|
|
20986
|
+
this._apply([]);
|
|
20987
|
+
return this.items;
|
|
20988
|
+
}
|
|
20989
|
+
if (raw && typeof raw.then === 'function') {
|
|
20990
|
+
const asked = this.value;
|
|
20991
|
+
return Promise.resolve(raw).then(
|
|
20992
|
+
(items) => (ticket === this._ticket && asked === this.value ? this._apply(items) : this.items),
|
|
20993
|
+
() => (ticket === this._ticket ? this._apply([]) : this.items),
|
|
20994
|
+
);
|
|
20995
|
+
}
|
|
20996
|
+
return this._apply(raw);
|
|
19650
20997
|
}
|
|
19651
20998
|
|
|
19652
|
-
/**
|
|
19653
|
-
|
|
20999
|
+
/** Shows `raw` suggestions (anything not a list shows none). */
|
|
21000
|
+
_apply(raw) {
|
|
21001
|
+
this.items = Array.isArray(raw)
|
|
21002
|
+
? raw.slice(0, this.maxSuggestions).map((item) => AskView.normalizeItem(item, this.value))
|
|
21003
|
+
: [];
|
|
21004
|
+
this.selectedIndex = -1;
|
|
21005
|
+
this.open = this.items.length > 0;
|
|
21006
|
+
this._changed();
|
|
21007
|
+
return this.items;
|
|
21008
|
+
}
|
|
21009
|
+
|
|
21010
|
+
/**
|
|
21011
|
+
* Moves the active highlight up (`-1`) or down (`+1`) in the scrollable list, wrapping around.
|
|
21012
|
+
* From no selection, down goes to the first item and up to the last.
|
|
21013
|
+
*/
|
|
21014
|
+
move(delta) {
|
|
21015
|
+
if (!this.open || this.items.length === 0) {
|
|
21016
|
+
this.refresh();
|
|
21017
|
+
return this.selectedIndex;
|
|
21018
|
+
}
|
|
21019
|
+
const len = this.items.length;
|
|
21020
|
+
this.selectedIndex = this.selectedIndex < 0
|
|
21021
|
+
? (delta > 0 ? 0 : len - 1)
|
|
21022
|
+
: (this.selectedIndex + delta + len) % len;
|
|
21023
|
+
this._changed();
|
|
21024
|
+
return this.selectedIndex;
|
|
21025
|
+
}
|
|
21026
|
+
|
|
21027
|
+
/**
|
|
21028
|
+
* Applies the suggestion at `index` (defaults to `selectedIndex`, or `0`), replacing `value`
|
|
21029
|
+
* with `item.completion` and immediately predicting the next word.
|
|
21030
|
+
*/
|
|
21031
|
+
accept(index = this.selectedIndex >= 0 ? this.selectedIndex : 0) {
|
|
21032
|
+
if (index < 0 || index >= this.items.length) return null;
|
|
21033
|
+
const item = this.items[index];
|
|
21034
|
+
this.value = item.completion;
|
|
21035
|
+
this.onComplete?.(item, this);
|
|
21036
|
+
if (this.predict) {
|
|
21037
|
+
this.refresh();
|
|
21038
|
+
} else {
|
|
21039
|
+
this.close();
|
|
21040
|
+
}
|
|
21041
|
+
return item;
|
|
21042
|
+
}
|
|
21043
|
+
|
|
21044
|
+
/** Closes the suggestion popup without changing `value`. */
|
|
21045
|
+
close() {
|
|
21046
|
+
if (!this.open && this.selectedIndex === -1) return;
|
|
21047
|
+
this.open = false;
|
|
21048
|
+
this.selectedIndex = -1;
|
|
21049
|
+
this._changed();
|
|
21050
|
+
}
|
|
21051
|
+
|
|
21052
|
+
/** Submits the current question (`value`), closes the suggestion list, and invokes `onAsk`. */
|
|
21053
|
+
submit(question = this.value) {
|
|
21054
|
+
this.value = String(question ?? '');
|
|
21055
|
+
this.open = false;
|
|
21056
|
+
this.selectedIndex = -1;
|
|
21057
|
+
const trimmed = this.value.trim();
|
|
21058
|
+
if (trimmed && this.history.at(-1) !== trimmed) {
|
|
21059
|
+
this.history.push(trimmed);
|
|
21060
|
+
}
|
|
21061
|
+
this._changed();
|
|
21062
|
+
return this.onAsk?.(this.value, this);
|
|
21063
|
+
}
|
|
21064
|
+
|
|
21065
|
+
_changed() {
|
|
21066
|
+
this.onChange?.(this);
|
|
21067
|
+
}
|
|
21068
|
+
|
|
21069
|
+
/** Normalizes a string or suggestion object into `{ display, completion, sense, reference, relation, kind }`. */
|
|
21070
|
+
static normalizeItem(item, currentText = '') {
|
|
21071
|
+
if (typeof item === 'string') {
|
|
21072
|
+
return {
|
|
21073
|
+
display: item,
|
|
21074
|
+
completion: AskView._completeWord(currentText, item),
|
|
21075
|
+
sense: null,
|
|
21076
|
+
reference: null,
|
|
21077
|
+
relation: null,
|
|
21078
|
+
kind: 'suggestion',
|
|
21079
|
+
};
|
|
21080
|
+
}
|
|
21081
|
+
const display = String(item.display ?? item.word ?? item.label ?? item.completion ?? '');
|
|
21082
|
+
const completion = item.completion !== undefined
|
|
21083
|
+
? String(item.completion)
|
|
21084
|
+
: AskView._completeWord(currentText, display);
|
|
21085
|
+
return {
|
|
21086
|
+
display,
|
|
21087
|
+
completion,
|
|
21088
|
+
sense: item.sense ?? null,
|
|
21089
|
+
reference: item.reference ?? null,
|
|
21090
|
+
relation: item.relation ?? null,
|
|
21091
|
+
kind: item.kind ?? 'suggestion',
|
|
21092
|
+
};
|
|
21093
|
+
}
|
|
21094
|
+
|
|
21095
|
+
/** Replaces the trailing partial token of `currentText` with `word` (followed by a space). */
|
|
21096
|
+
static _completeWord(currentText, word) {
|
|
21097
|
+
const text = String(currentText ?? '');
|
|
21098
|
+
if (word.includes(' ') && word.toLowerCase().startsWith(text.trim().toLowerCase()) && text.trim().length > 0) {
|
|
21099
|
+
return word;
|
|
21100
|
+
}
|
|
21101
|
+
const match = text.match(/^(.*?)(\S*)$/);
|
|
21102
|
+
const prefix = match ? match[1] : '';
|
|
21103
|
+
const cleanWord = String(word).trim();
|
|
21104
|
+
return `${prefix}${cleanWord} `;
|
|
21105
|
+
}
|
|
21106
|
+
|
|
21107
|
+
/**
|
|
21108
|
+
* Builds a context-aware next-word predictor from declarative cube/table metadata and/or
|
|
21109
|
+
* sample questions. Predicts metrics, dimensions, clauses (`by`, `and`, `where`, `top`, `vs`),
|
|
21110
|
+
* sampled dimension members after `=`, and learned next-word bigrams from `sampleQuestions`.
|
|
21111
|
+
*/
|
|
21112
|
+
static nextWordPredictor({
|
|
21113
|
+
measures = [],
|
|
21114
|
+
dimensions = [],
|
|
21115
|
+
members = null,
|
|
21116
|
+
vocabulary = [],
|
|
21117
|
+
sampleQuestions = [],
|
|
21118
|
+
} = {}) {
|
|
21119
|
+
const catalog = [];
|
|
21120
|
+
const addEntry = (display, kind, sense = null, reference = null, relation = null) => {
|
|
21121
|
+
if (!display) return;
|
|
21122
|
+
if (catalog.some(c => c.display.toLowerCase() === String(display).toLowerCase() && c.kind === kind)) return;
|
|
21123
|
+
catalog.push({ display: String(display), kind, sense, reference, relation });
|
|
21124
|
+
};
|
|
21125
|
+
|
|
21126
|
+
for (const m of measures) addEntry(m, 'metric', `Measure ${m}`, m, 'canonical');
|
|
21127
|
+
for (const d of dimensions) addEntry(d, 'dimension', `Dimension ${d}`, d, 'canonical');
|
|
21128
|
+
for (const v of vocabulary) {
|
|
21129
|
+
if (typeof v === 'string') addEntry(v, 'term');
|
|
21130
|
+
else addEntry(v.display ?? v.word ?? v.label, v.kind ?? 'term', v.sense ?? null, v.reference ?? null, v.relation ?? null);
|
|
21131
|
+
}
|
|
21132
|
+
|
|
21133
|
+
const keywords = [
|
|
21134
|
+
{ display: 'by', kind: 'keyword', sense: 'Group or slice by dimension' },
|
|
21135
|
+
{ display: 'and', kind: 'keyword', sense: 'Add another measure or dimension' },
|
|
21136
|
+
{ display: 'where', kind: 'keyword', sense: 'Filter by dimension member' },
|
|
21137
|
+
{ display: 'top', kind: 'keyword', sense: 'Rank top N members' },
|
|
21138
|
+
{ display: '=', kind: 'operator', sense: 'Equals member value' },
|
|
21139
|
+
{ display: 'in', kind: 'keyword', sense: 'Match any of several members' },
|
|
21140
|
+
{ display: 'vs', kind: 'keyword', sense: 'Compare against another measure or period' },
|
|
21141
|
+
{ display: 'descending', kind: 'keyword', sense: 'Sort highest first' },
|
|
21142
|
+
{ display: 'ascending', kind: 'keyword', sense: 'Sort lowest first' },
|
|
21143
|
+
];
|
|
21144
|
+
for (const kw of keywords) addEntry(kw.display, kw.kind, kw.sense);
|
|
21145
|
+
|
|
21146
|
+
// Learn next-word transitions (bigrams) from sampleQuestions.
|
|
21147
|
+
const bigrams = new Map();
|
|
21148
|
+
for (const q of sampleQuestions) {
|
|
21149
|
+
const tokens = String(q).trim().split(/\s+/).filter(Boolean);
|
|
21150
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
21151
|
+
const prev = tokens[i].replace(/,+$/, '').toLowerCase();
|
|
21152
|
+
const next = tokens[i + 1].replace(/,+$/, '');
|
|
21153
|
+
if (!bigrams.has(prev)) bigrams.set(prev, new Set());
|
|
21154
|
+
bigrams.get(prev).add(next);
|
|
21155
|
+
addEntry(tokens[i].replace(/,+$/, ''), 'term');
|
|
21156
|
+
}
|
|
21157
|
+
if (tokens.length > 0) addEntry(tokens.at(-1).replace(/,+$/, ''), 'term');
|
|
21158
|
+
}
|
|
21159
|
+
|
|
21160
|
+
const getMembers = (dim) => {
|
|
21161
|
+
if (!dim || !members) return [];
|
|
21162
|
+
if (typeof members === 'function') return members(dim) ?? [];
|
|
21163
|
+
const foundKey = Object.keys(members).find(k => k.toLowerCase() === dim.toLowerCase());
|
|
21164
|
+
return foundKey ? members[foundKey] ?? [] : [];
|
|
21165
|
+
};
|
|
21166
|
+
|
|
21167
|
+
return (text) => {
|
|
21168
|
+
const raw = String(text ?? '');
|
|
21169
|
+
const match = raw.match(/^(.*?)(\S*)$/);
|
|
21170
|
+
const before = match ? match[1] : '';
|
|
21171
|
+
const partial = (match ? match[2] : '').toLowerCase();
|
|
21172
|
+
const priorTokens = before.trim().split(/\s+/).filter(Boolean);
|
|
21173
|
+
const lastToken = (priorTokens.at(-1) ?? '').replace(/,+$/, '').toLowerCase();
|
|
21174
|
+
const prevToken = (priorTokens.at(-2) ?? '').replace(/,+$/, '').toLowerCase();
|
|
21175
|
+
|
|
21176
|
+
const results = [];
|
|
21177
|
+
const push = (entry, bonus = 0) => {
|
|
21178
|
+
const word = String(entry.display);
|
|
21179
|
+
if (partial && !word.toLowerCase().startsWith(partial)) return;
|
|
21180
|
+
if (results.some(r => r.display.toLowerCase() === word.toLowerCase())) return;
|
|
21181
|
+
results.push({
|
|
21182
|
+
display: word,
|
|
21183
|
+
completion: AskView._completeWord(raw, word),
|
|
21184
|
+
sense: entry.sense ?? null,
|
|
21185
|
+
reference: entry.reference ?? null,
|
|
21186
|
+
relation: entry.relation ?? null,
|
|
21187
|
+
kind: entry.kind ?? 'suggestion',
|
|
21188
|
+
_score: bonus + (word.toLowerCase() === partial ? 0 : 10),
|
|
21189
|
+
});
|
|
21190
|
+
};
|
|
21191
|
+
|
|
21192
|
+
// 1. After `<dimension> =`, suggest sampled member values of that dimension.
|
|
21193
|
+
if (lastToken === '=' && prevToken) {
|
|
21194
|
+
const dimName = dimensions.find(d => d.toLowerCase() === prevToken) ?? prevToken;
|
|
21195
|
+
for (const val of getMembers(dimName)) {
|
|
21196
|
+
push({
|
|
21197
|
+
display: String(val),
|
|
21198
|
+
kind: 'value',
|
|
21199
|
+
sense: `Member of ${dimName}`,
|
|
21200
|
+
reference: `${dimName} = ${val}`,
|
|
21201
|
+
}, 100);
|
|
21202
|
+
}
|
|
21203
|
+
}
|
|
21204
|
+
|
|
21205
|
+
// 2. Context-sensitive grammar transitions.
|
|
21206
|
+
if (!lastToken) {
|
|
21207
|
+
for (const c of catalog.filter(e => e.kind === 'metric')) push(c, 80);
|
|
21208
|
+
for (const c of catalog.filter(e => e.display === 'top')) push(c, 70);
|
|
21209
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 60);
|
|
21210
|
+
} else if (lastToken === 'by' || lastToken === 'where') {
|
|
21211
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 90);
|
|
21212
|
+
} else if (lastToken === 'and') {
|
|
21213
|
+
const inByClause = priorTokens.some(t => t.toLowerCase() === 'by');
|
|
21214
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'dimension' : 'metric'))) push(c, 90);
|
|
21215
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'metric' : 'dimension'))) push(c, 70);
|
|
21216
|
+
} else if (dimensions.some(d => d.toLowerCase() === lastToken)) {
|
|
21217
|
+
const inWhere = priorTokens.some(t => t.toLowerCase() === 'where');
|
|
21218
|
+
if (inWhere) {
|
|
21219
|
+
for (const c of catalog.filter(e => e.display === '=')) push(c, 95);
|
|
21220
|
+
} else {
|
|
21221
|
+
for (const c of catalog.filter(e => ['and', 'where', 'descending', 'ascending'].includes(e.display))) push(c, 85);
|
|
21222
|
+
}
|
|
21223
|
+
} else if (measures.some(m => m.toLowerCase() === lastToken)) {
|
|
21224
|
+
for (const c of catalog.filter(e => ['by', 'and', 'where', 'vs'].includes(e.display))) push(c, 90);
|
|
21225
|
+
}
|
|
21226
|
+
|
|
21227
|
+
// 3. Learned bigram transitions from sample questions.
|
|
21228
|
+
if (lastToken && bigrams.has(lastToken)) {
|
|
21229
|
+
for (const nextWord of bigrams.get(lastToken)) {
|
|
21230
|
+
const existing = catalog.find(c => c.display.toLowerCase() === nextWord.toLowerCase());
|
|
21231
|
+
push(existing ?? { display: nextWord, kind: 'next word', sense: `Follows "${lastToken}"` }, 88);
|
|
21232
|
+
}
|
|
21233
|
+
}
|
|
21234
|
+
|
|
21235
|
+
// 4. All remaining catalog entries matching the partial prefix.
|
|
21236
|
+
for (const c of catalog) push(c, 20);
|
|
21237
|
+
|
|
21238
|
+
return results
|
|
21239
|
+
.sort((a, b) => b._score - a._score)
|
|
21240
|
+
.map(({ _score, ...rest }) => rest);
|
|
21241
|
+
};
|
|
21242
|
+
}
|
|
21243
|
+
}
|
|
21244
|
+
|
|
21245
|
+
/**
|
|
21246
|
+
* AskBar — a self-contained browser UI component for asking questions with preemptive
|
|
21247
|
+
* next-word prediction in a scrollable list (generalizing the Ask bar from
|
|
21248
|
+
* `semanticengine/examples/browser/index.html` into a reusable RenderEngine capability).
|
|
21249
|
+
*
|
|
21250
|
+
* Follows the same OO encapsulation pattern as `ArchimateDiagramEditor`:
|
|
21251
|
+
* - Owns DOM creation, CSS injection, event wiring, and keyboard/scroll management.
|
|
21252
|
+
* - Separates pure state and prediction logic into `AskView` (`this.view`) so all logic is
|
|
21253
|
+
* DOM-free testable under Node.js.
|
|
21254
|
+
* - Includes a configurable **Predict next word** selector allowing the user (or host) to
|
|
21255
|
+
* choose how many predicted items are visible at once in the scrollable list (e.g. `Off`,
|
|
21256
|
+
* `3`, `5`, `8`, `10`), while scrolling (`overflow-y: auto`) or pressing `↑`/`↓` browses
|
|
21257
|
+
* through the full list of predictions.
|
|
21258
|
+
*/
|
|
21259
|
+
class AskBar {
|
|
21260
|
+
/** Approximate height in pixels of one suggestion row in the scrollable list. */
|
|
21261
|
+
static ITEM_HEIGHT = 44;
|
|
21262
|
+
|
|
21263
|
+
/** Numbers each bar's list, so several bars on one page each name their own (ARIA ids). */
|
|
21264
|
+
static _count = 0;
|
|
21265
|
+
|
|
21266
|
+
/**
|
|
21267
|
+
* @param {HTMLElement|string} container host element or its DOM id
|
|
21268
|
+
* @param {object} [options] all `AskView` options plus UI options:
|
|
21269
|
+
* @param {Document} [options.document] DOM document (defaults to `globalThis.document`)
|
|
21270
|
+
* @param {string} [options.placeholder='Ask a question…'] input placeholder
|
|
21271
|
+
* @param {string} [options.buttonLabel='Run'] submit button label
|
|
21272
|
+
* @param {string} [options.ariaLabel='Question'] input aria-label
|
|
21273
|
+
* @param {boolean} [options.showPredictControl=true] whether to show the configurable "Predict next word" selector
|
|
21274
|
+
* @param {string} [options.predictLabel='Predict next word:'] label for the predict selector
|
|
21275
|
+
* @param {boolean} [options.autoRun=false] whether to run `onAsk` immediately upon mounting
|
|
21276
|
+
* @param {object} [options.resultEngine] optional `RenderEngine` instance whose `resultView()` displays `{ rows, columns, complete }` returned by `onAsk`
|
|
21277
|
+
*/
|
|
21278
|
+
constructor(container, options = {}) {
|
|
21279
|
+
this.options = options;
|
|
21280
|
+
this.doc = options.document ?? container?.ownerDocument ?? globalThis.document ?? null;
|
|
21281
|
+
this.container = typeof container === 'string'
|
|
21282
|
+
? this.doc?.getElementById?.(container) ?? null
|
|
21283
|
+
: container ?? null;
|
|
21284
|
+
this.resultEngine = options.resultEngine ?? null;
|
|
21285
|
+
|
|
21286
|
+
const userOnAsk = options.onAsk;
|
|
21287
|
+
this.listId = `re-ask-list-${++AskBar._count}`;
|
|
21288
|
+
this._blurTimer = null;
|
|
21289
|
+
// An answer (`{ rows, columns, complete }`), or a promise of one, is shown by resultEngine.
|
|
21290
|
+
const show = (res) => {
|
|
21291
|
+
if (res && typeof res === 'object' && Array.isArray(res.rows) && this.resultEngine?.resultView) {
|
|
21292
|
+
this.resultEngine.resultView(res.rows, {
|
|
21293
|
+
columns: res.columns,
|
|
21294
|
+
complete: res.complete,
|
|
21295
|
+
pageSize: options.pageSize ?? 10,
|
|
21296
|
+
});
|
|
21297
|
+
}
|
|
21298
|
+
return res;
|
|
21299
|
+
};
|
|
21300
|
+
this.view = options.view instanceof AskView ? options.view : new AskView({
|
|
21301
|
+
...options,
|
|
21302
|
+
onAsk: (question, view) => {
|
|
21303
|
+
const res = userOnAsk?.(question, view, this);
|
|
21304
|
+
return res && typeof res.then === 'function' ? Promise.resolve(res).then(show) : show(res);
|
|
21305
|
+
},
|
|
21306
|
+
onChange: (view) => {
|
|
21307
|
+
this._syncFromView();
|
|
21308
|
+
options.onChange?.(view, this);
|
|
21309
|
+
},
|
|
21310
|
+
});
|
|
21311
|
+
|
|
21312
|
+
this.root = null;
|
|
21313
|
+
this.inputEl = null;
|
|
21314
|
+
this.suggestEl = null;
|
|
21315
|
+
this.predictSelectEl = null;
|
|
21316
|
+
this.samplesEl = null;
|
|
21317
|
+
|
|
21318
|
+
if (this.container && this.doc) {
|
|
21319
|
+
this.render();
|
|
21320
|
+
if (options.autoRun && this.view.value) {
|
|
21321
|
+
this.view.submit(this.view.value);
|
|
21322
|
+
}
|
|
21323
|
+
}
|
|
21324
|
+
}
|
|
21325
|
+
|
|
21326
|
+
/** Injects the component's scoped stylesheet once per document, build-free and theme-aware. */
|
|
21327
|
+
static _injectStyles(doc) {
|
|
21328
|
+
if (!doc?.head || doc.getElementById?.('re-ask-bar-styles')) return;
|
|
21329
|
+
const style = doc.createElement('style');
|
|
21330
|
+
style.id = 're-ask-bar-styles';
|
|
21331
|
+
style.textContent = `
|
|
21332
|
+
.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)); }
|
|
21333
|
+
.re-ask-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 0; }
|
|
21334
|
+
.re-ask-input-wrapper { position: relative; flex: 1 1 260px; min-width: 180px; }
|
|
21335
|
+
.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; }
|
|
21336
|
+
.re-ask-input:focus { outline: 2px solid var(--ws-accent, var(--accent, #2b5fd9)); outline-offset: -1px; }
|
|
21337
|
+
.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; }
|
|
21338
|
+
.re-ask-submit:hover { filter: brightness(1.06); }
|
|
21339
|
+
.re-ask-predict-ctrl { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ws-muted, var(--muted, #666)); }
|
|
21340
|
+
.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; }
|
|
21341
|
+
.re-ask-suggest-box {
|
|
21342
|
+
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 250;
|
|
21343
|
+
background: var(--ws-panel-bg, var(--bg, #fff));
|
|
21344
|
+
border: 1px solid var(--ws-border, var(--line, #ccc));
|
|
21345
|
+
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
|
21346
|
+
overflow-y: auto; display: flex; flex-direction: column;
|
|
21347
|
+
}
|
|
21348
|
+
.re-ask-suggest-box[hidden] { display: none !important; }
|
|
21349
|
+
.re-ask-suggest-item {
|
|
21350
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
21351
|
+
padding: 7px 10px; cursor: pointer; font-size: 13px; gap: 8px; user-select: none;
|
|
21352
|
+
box-sizing: border-box; min-height: 36px;
|
|
21353
|
+
}
|
|
21354
|
+
.re-ask-suggest-item:hover, .re-ask-suggest-item.active {
|
|
21355
|
+
background: var(--ws-accent, var(--accent, #2b5fd9)); color: #fff;
|
|
21356
|
+
}
|
|
21357
|
+
.re-ask-suggest-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
21358
|
+
.re-ask-suggest-text { font-family: ui-monospace, monospace; font-size: 13px; }
|
|
21359
|
+
.re-ask-suggest-sense { font-size: 11px; color: var(--ws-muted, var(--muted, #6b6b70)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
21360
|
+
.re-ask-suggest-item:hover .re-ask-suggest-sense,
|
|
21361
|
+
.re-ask-suggest-item.active .re-ask-suggest-sense { color: rgba(255, 255, 255, 0.85); }
|
|
21362
|
+
.re-ask-suggest-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
|
|
21363
|
+
.re-ask-suggest-badge {
|
|
21364
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
|
21365
|
+
padding: 2px 6px; border-radius: 4px;
|
|
21366
|
+
background: var(--ws-border, var(--line, #e0e0e4));
|
|
21367
|
+
color: var(--ws-muted, var(--muted, #555)); flex-shrink: 0;
|
|
21368
|
+
}
|
|
21369
|
+
.re-ask-suggest-item:hover .re-ask-suggest-badge,
|
|
21370
|
+
.re-ask-suggest-item.active .re-ask-suggest-badge {
|
|
21371
|
+
background: rgba(255, 255, 255, 0.25); color: #fff;
|
|
21372
|
+
}
|
|
21373
|
+
.re-ask-samples { font-size: 12px; color: var(--ws-muted, var(--muted, #666)); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
21374
|
+
.re-ask-sample-pill {
|
|
21375
|
+
font-family: ui-monospace, monospace; font-size: 11.5px;
|
|
21376
|
+
padding: 2px 6px; border-radius: 4px; cursor: pointer;
|
|
21377
|
+
background: rgba(127,127,127,0.12); border: 1px solid var(--ws-border, var(--line, #ddd));
|
|
21378
|
+
color: var(--ws-fg, var(--fg, #222));
|
|
21379
|
+
}
|
|
21380
|
+
.re-ask-sample-pill:hover { border-color: var(--ws-accent, var(--accent, #2b5fd9)); }
|
|
21381
|
+
`;
|
|
21382
|
+
doc.head.appendChild(style);
|
|
21383
|
+
}
|
|
21384
|
+
|
|
21385
|
+
/** Builds the AskBar DOM inside `this.container`. */
|
|
21386
|
+
render() {
|
|
21387
|
+
if (!this.container || !this.doc) return this;
|
|
21388
|
+
AskBar._injectStyles(this.doc);
|
|
21389
|
+
|
|
21390
|
+
const root = this.doc.createElement('div');
|
|
21391
|
+
root.className = 're-ask-bar';
|
|
21392
|
+
|
|
21393
|
+
const form = this.doc.createElement('form');
|
|
21394
|
+
form.className = 're-ask-form';
|
|
21395
|
+
|
|
21396
|
+
const wrapper = this.doc.createElement('div');
|
|
21397
|
+
wrapper.className = 're-ask-input-wrapper';
|
|
21398
|
+
|
|
21399
|
+
const input = this.doc.createElement('input');
|
|
21400
|
+
input.className = 're-ask-input';
|
|
21401
|
+
input.type = 'text';
|
|
21402
|
+
input.value = this.view.value;
|
|
21403
|
+
input.placeholder = this.options.placeholder ?? 'Ask a question…';
|
|
21404
|
+
input.setAttribute('aria-label', this.options.ariaLabel ?? 'Question');
|
|
21405
|
+
input.setAttribute('autocomplete', 'off');
|
|
21406
|
+
input.setAttribute('spellcheck', 'false');
|
|
21407
|
+
// A combobox over its list of completions (WAI-ARIA 1.2), so a screen reader follows ↑/↓.
|
|
21408
|
+
input.setAttribute('role', 'combobox');
|
|
21409
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
21410
|
+
input.setAttribute('aria-controls', this.listId);
|
|
21411
|
+
input.setAttribute('aria-expanded', 'false');
|
|
21412
|
+
|
|
21413
|
+
const suggestBox = this.doc.createElement('div');
|
|
21414
|
+
suggestBox.className = 're-ask-suggest-box';
|
|
21415
|
+
suggestBox.id = this.listId;
|
|
21416
|
+
suggestBox.hidden = true;
|
|
21417
|
+
suggestBox.setAttribute('role', 'listbox');
|
|
21418
|
+
suggestBox.setAttribute('aria-label', 'Suggested completions');
|
|
21419
|
+
|
|
21420
|
+
wrapper.appendChild(input);
|
|
21421
|
+
wrapper.appendChild(suggestBox);
|
|
21422
|
+
form.appendChild(wrapper);
|
|
21423
|
+
|
|
21424
|
+
const submitBtn = this.doc.createElement('button');
|
|
21425
|
+
submitBtn.type = 'submit';
|
|
21426
|
+
submitBtn.className = 're-ask-submit';
|
|
21427
|
+
submitBtn.textContent = this.options.buttonLabel ?? 'Run';
|
|
21428
|
+
form.appendChild(submitBtn);
|
|
21429
|
+
|
|
21430
|
+
if (this.options.showPredictControl !== false) {
|
|
21431
|
+
const ctrlLabel = this.doc.createElement('label');
|
|
21432
|
+
ctrlLabel.className = 're-ask-predict-ctrl';
|
|
21433
|
+
ctrlLabel.textContent = this.options.predictLabel ?? 'Predict next word:';
|
|
21434
|
+
|
|
21435
|
+
const select = this.doc.createElement('select');
|
|
21436
|
+
select.className = 're-ask-predict-select';
|
|
21437
|
+
select.setAttribute('aria-label', 'Predict next word in scrollable list');
|
|
21438
|
+
for (const choice of this.view.predictChoices) {
|
|
21439
|
+
const n = Number(choice);
|
|
21440
|
+
const text = n <= 0 ? 'Off' : `${n} visible (scrollable)`;
|
|
21441
|
+
const opt = this.doc.createElement('option');
|
|
21442
|
+
opt.value = String(n);
|
|
21443
|
+
opt.textContent = text;
|
|
21444
|
+
if (n === this.view.predictCount) opt.selected = true;
|
|
21445
|
+
select.appendChild(opt);
|
|
21446
|
+
}
|
|
21447
|
+
select.addEventListener('change', () => {
|
|
21448
|
+
this.view.setPredictCount(Number(select.value));
|
|
21449
|
+
});
|
|
21450
|
+
ctrlLabel.appendChild(select);
|
|
21451
|
+
form.appendChild(ctrlLabel);
|
|
21452
|
+
this.predictSelectEl = select;
|
|
21453
|
+
}
|
|
21454
|
+
|
|
21455
|
+
root.appendChild(form);
|
|
21456
|
+
|
|
21457
|
+
if (this.view.samples.length > 0) {
|
|
21458
|
+
const samplesRow = this.doc.createElement('div');
|
|
21459
|
+
samplesRow.className = 're-ask-samples';
|
|
21460
|
+
const prefix = this.doc.createElement('span');
|
|
21461
|
+
prefix.textContent = 'Try:';
|
|
21462
|
+
samplesRow.appendChild(prefix);
|
|
21463
|
+
for (const q of this.view.samples) {
|
|
21464
|
+
const pill = this.doc.createElement('code');
|
|
21465
|
+
pill.className = 're-ask-sample-pill';
|
|
21466
|
+
pill.textContent = q;
|
|
21467
|
+
pill.addEventListener('click', () => {
|
|
21468
|
+
this.view.submit(q);
|
|
21469
|
+
});
|
|
21470
|
+
samplesRow.appendChild(pill);
|
|
21471
|
+
}
|
|
21472
|
+
root.appendChild(samplesRow);
|
|
21473
|
+
this.samplesEl = samplesRow;
|
|
21474
|
+
}
|
|
21475
|
+
|
|
21476
|
+
this.root = root;
|
|
21477
|
+
this.inputEl = input;
|
|
21478
|
+
this.suggestEl = suggestBox;
|
|
21479
|
+
this._wireEvents(form, input);
|
|
21480
|
+
this.container.replaceChildren ? this.container.replaceChildren(root) : (this.container.innerHTML = '', this.container.appendChild(root));
|
|
21481
|
+
this._syncFromView();
|
|
21482
|
+
return this;
|
|
21483
|
+
}
|
|
21484
|
+
|
|
21485
|
+
_wireEvents(form, input) {
|
|
21486
|
+
input.addEventListener('input', () => {
|
|
21487
|
+
this.view.setInput(input.value, { suggest: true });
|
|
21488
|
+
});
|
|
21489
|
+
input.addEventListener('focus', () => {
|
|
21490
|
+
if (this.view.predict) this.view.refresh();
|
|
21491
|
+
});
|
|
21492
|
+
input.addEventListener('blur', () => {
|
|
21493
|
+
this._blurTimer = setTimeout(() => { this._blurTimer = null; this.view.close(); }, 150);
|
|
21494
|
+
});
|
|
21495
|
+
input.addEventListener('keydown', (e) => {
|
|
21496
|
+
if (!this.view.open || this.view.items.length === 0) {
|
|
21497
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
21498
|
+
this.view.refresh();
|
|
21499
|
+
}
|
|
21500
|
+
return;
|
|
21501
|
+
}
|
|
21502
|
+
if (e.key === 'ArrowDown') {
|
|
21503
|
+
e.preventDefault();
|
|
21504
|
+
this.view.move(1);
|
|
21505
|
+
} else if (e.key === 'ArrowUp') {
|
|
21506
|
+
e.preventDefault();
|
|
21507
|
+
this.view.move(-1);
|
|
21508
|
+
} else if (e.key === 'Tab') {
|
|
21509
|
+
e.preventDefault();
|
|
21510
|
+
this.view.accept(this.view.selectedIndex >= 0 ? this.view.selectedIndex : 0);
|
|
21511
|
+
this.inputEl?.focus?.();
|
|
21512
|
+
} else if (e.key === 'Enter' && this.view.selectedIndex >= 0) {
|
|
21513
|
+
e.preventDefault();
|
|
21514
|
+
this.view.accept(this.view.selectedIndex);
|
|
21515
|
+
this.inputEl?.focus?.();
|
|
21516
|
+
} else if (e.key === 'Escape') {
|
|
21517
|
+
this.view.close();
|
|
21518
|
+
}
|
|
21519
|
+
});
|
|
21520
|
+
form.addEventListener('submit', (e) => {
|
|
21521
|
+
e.preventDefault();
|
|
21522
|
+
this.view.submit(input.value);
|
|
21523
|
+
});
|
|
21524
|
+
}
|
|
21525
|
+
|
|
21526
|
+
/** Swaps where suggestions come from (`AskView.setSuggest`): another model or ontology. */
|
|
21527
|
+
setSuggest(suggest) {
|
|
21528
|
+
this.view.setSuggest(suggest);
|
|
21529
|
+
return this;
|
|
21530
|
+
}
|
|
21531
|
+
|
|
21532
|
+
/** Sets the question shown, without suggesting. */
|
|
21533
|
+
setValue(text) {
|
|
21534
|
+
this.view.setInput(text, { suggest: false });
|
|
21535
|
+
return this;
|
|
21536
|
+
}
|
|
21537
|
+
|
|
21538
|
+
/** Puts the cursor in the question. */
|
|
21539
|
+
focus() {
|
|
21540
|
+
this.inputEl?.focus?.();
|
|
21541
|
+
return this;
|
|
21542
|
+
}
|
|
21543
|
+
|
|
21544
|
+
/** Takes the bar out of its container; a pending close is cancelled. */
|
|
21545
|
+
destroy() {
|
|
21546
|
+
if (this._blurTimer !== null) clearTimeout(this._blurTimer);
|
|
21547
|
+
this._blurTimer = null;
|
|
21548
|
+
this.root?.remove?.();
|
|
21549
|
+
this.root = this.inputEl = this.suggestEl = this.predictSelectEl = this.samplesEl = null;
|
|
21550
|
+
}
|
|
21551
|
+
|
|
21552
|
+
_syncFromView() {
|
|
21553
|
+
if (!this.inputEl || !this.suggestEl) return;
|
|
21554
|
+
if (this.inputEl.value !== this.view.value) {
|
|
21555
|
+
this.inputEl.value = this.view.value;
|
|
21556
|
+
}
|
|
21557
|
+
if (this.predictSelectEl) {
|
|
21558
|
+
const val = String(this.view.predictCount);
|
|
21559
|
+
if (this.predictSelectEl.value !== val) this.predictSelectEl.value = val;
|
|
21560
|
+
}
|
|
21561
|
+
|
|
21562
|
+
const active = this.view.selectedIndex;
|
|
21563
|
+
if (active >= 0 && this.view.open) this.inputEl.setAttribute('aria-activedescendant', `${this.listId}-${active}`);
|
|
21564
|
+
else this.inputEl.removeAttribute?.('aria-activedescendant');
|
|
21565
|
+
|
|
21566
|
+
if (!this.view.open || !this.view.predict || this.view.items.length === 0) {
|
|
21567
|
+
this.inputEl.setAttribute('aria-expanded', 'false');
|
|
21568
|
+
this.suggestEl.hidden = true;
|
|
21569
|
+
if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
|
|
21570
|
+
return;
|
|
21571
|
+
}
|
|
21572
|
+
|
|
21573
|
+
this.suggestEl.style.maxHeight = `${Math.max(1, this.view.visibleCount) * AskBar.ITEM_HEIGHT}px`;
|
|
21574
|
+
if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
|
|
21575
|
+
|
|
21576
|
+
this.view.items.forEach((item, idx) => {
|
|
21577
|
+
const row = this.doc.createElement('div');
|
|
21578
|
+
row.className = `re-ask-suggest-item${idx === this.view.selectedIndex ? ' active' : ''}`;
|
|
21579
|
+
row.dataset.index = String(idx);
|
|
21580
|
+
row.id = `${this.listId}-${idx}`;
|
|
21581
|
+
row.setAttribute('role', 'option');
|
|
21582
|
+
row.setAttribute('aria-selected', String(idx === this.view.selectedIndex));
|
|
21583
|
+
|
|
21584
|
+
const mainCol = this.doc.createElement('div');
|
|
21585
|
+
mainCol.className = 're-ask-suggest-main';
|
|
21586
|
+
|
|
21587
|
+
const textSpan = this.doc.createElement('span');
|
|
21588
|
+
textSpan.className = 're-ask-suggest-text';
|
|
21589
|
+
textSpan.textContent = item.display;
|
|
21590
|
+
mainCol.appendChild(textSpan);
|
|
21591
|
+
|
|
21592
|
+
if (item.sense) {
|
|
21593
|
+
const senseSpan = this.doc.createElement('span');
|
|
21594
|
+
senseSpan.className = 're-ask-suggest-sense';
|
|
21595
|
+
senseSpan.textContent = `${item.sense}${item.reference ? ` → ${item.reference}` : ''}`;
|
|
21596
|
+
mainCol.appendChild(senseSpan);
|
|
21597
|
+
}
|
|
21598
|
+
|
|
21599
|
+
const badges = this.doc.createElement('div');
|
|
21600
|
+
badges.className = 're-ask-suggest-badges';
|
|
21601
|
+
|
|
21602
|
+
if (item.relation && item.relation !== 'canonical' && item.relation !== 'function') {
|
|
21603
|
+
const relBadge = this.doc.createElement('span');
|
|
21604
|
+
relBadge.className = 're-ask-suggest-badge';
|
|
21605
|
+
relBadge.textContent = item.relation;
|
|
21606
|
+
badges.appendChild(relBadge);
|
|
21607
|
+
}
|
|
21608
|
+
if (item.kind) {
|
|
21609
|
+
const kindBadge = this.doc.createElement('span');
|
|
21610
|
+
kindBadge.className = 're-ask-suggest-badge';
|
|
21611
|
+
kindBadge.textContent = item.kind;
|
|
21612
|
+
badges.appendChild(kindBadge);
|
|
21613
|
+
}
|
|
21614
|
+
|
|
21615
|
+
row.appendChild(mainCol);
|
|
21616
|
+
row.appendChild(badges);
|
|
21617
|
+
row.addEventListener('mousedown', (e) => {
|
|
21618
|
+
e.preventDefault();
|
|
21619
|
+
this.view.accept(idx);
|
|
21620
|
+
this.inputEl?.focus?.();
|
|
21621
|
+
});
|
|
21622
|
+
this.suggestEl.appendChild(row);
|
|
21623
|
+
if (idx === this.view.selectedIndex && typeof row.scrollIntoView === 'function') {
|
|
21624
|
+
row.scrollIntoView({ block: 'nearest' });
|
|
21625
|
+
}
|
|
21626
|
+
});
|
|
21627
|
+
this.suggestEl.hidden = false;
|
|
21628
|
+
this.inputEl.setAttribute('aria-expanded', 'true');
|
|
21629
|
+
}
|
|
19654
21630
|
}
|
|
19655
21631
|
|
|
19656
21632
|
/**
|
|
@@ -19672,7 +21648,7 @@
|
|
|
19672
21648
|
} = options;
|
|
19673
21649
|
const isMulti = Array.isArray(data[0]?.values);
|
|
19674
21650
|
const series = isMulti ? data : [{ name: 'default', values: data }];
|
|
19675
|
-
const axes = series[0].values.map(v => v.axis);
|
|
21651
|
+
const axes = series[0].values.map(v => v.axis ?? v.label);
|
|
19676
21652
|
const n = axes.length;
|
|
19677
21653
|
const palette = new ColorPalette(colors);
|
|
19678
21654
|
const axisMax = axes.map((_, ai) => Math.max(...series.map(s => s.values[ai]?.value ?? 0)) || 1);
|
|
@@ -19710,9 +21686,10 @@
|
|
|
19710
21686
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
19711
21687
|
this.renderer.path(path, `rgba(${r},${g},${b},0.25)`, { color, width: 2 }, { tooltip: s.name });
|
|
19712
21688
|
pts.forEach((p, i) => {
|
|
19713
|
-
|
|
19714
|
-
|
|
19715
|
-
|
|
21689
|
+
const axisLabel = s.values[i].axis ?? s.values[i].label;
|
|
21690
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
21691
|
+
tooltip: `${s.name} — ${axisLabel}: ${s.values[i].value}`,
|
|
21692
|
+
}), { label: axisLabel, series: isMulti ? s.name : null });
|
|
19716
21693
|
});
|
|
19717
21694
|
});
|
|
19718
21695
|
|
|
@@ -19761,7 +21738,7 @@
|
|
|
19761
21738
|
const bottomRight = bottomLeft + bottomWidth;
|
|
19762
21739
|
|
|
19763
21740
|
const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
|
|
19764
|
-
this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
|
|
21741
|
+
this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
|
|
19765
21742
|
this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
|
|
19766
21743
|
textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
|
|
19767
21744
|
});
|
|
@@ -19825,9 +21802,9 @@
|
|
|
19825
21802
|
const top = toY(Math.max(band.from, band.to));
|
|
19826
21803
|
const bottom = toY(Math.min(band.from, band.to));
|
|
19827
21804
|
const color = band.isTotal ? totalColor : (band.to >= band.from ? upColor : downColor);
|
|
19828
|
-
this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
21805
|
+
this._datum(this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
19829
21806
|
tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
|
|
19830
|
-
});
|
|
21807
|
+
}), { label: d.label });
|
|
19831
21808
|
if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
|
|
19832
21809
|
});
|
|
19833
21810
|
}
|
|
@@ -20956,12 +22933,12 @@
|
|
|
20956
22933
|
const barBaseY = y + height;
|
|
20957
22934
|
const color = palette.colorAt(i);
|
|
20958
22935
|
|
|
20959
|
-
this.renderer.rect(
|
|
22936
|
+
this._datum(this.renderer.rect(
|
|
20960
22937
|
barX, barBaseY - barHeight, barWidth, barHeight,
|
|
20961
22938
|
color,
|
|
20962
22939
|
{ color: '#333', width: 1 },
|
|
20963
22940
|
{ tooltip: `${d.label}: ${d.value}` },
|
|
20964
|
-
);
|
|
22941
|
+
), { label: d.label });
|
|
20965
22942
|
|
|
20966
22943
|
// Top face
|
|
20967
22944
|
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`;
|
|
@@ -21045,8 +23022,9 @@
|
|
|
21045
23022
|
|
|
21046
23023
|
/**
|
|
21047
23024
|
* Draw one 3D box through the orbit camera. Returns average corner depth.
|
|
23025
|
+
* When `datum` is supplied (`{ label, series? }`), each face is recorded via `_datum()`.
|
|
21048
23026
|
*/
|
|
21049
|
-
drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen) {
|
|
23027
|
+
drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen, datum = null) {
|
|
21050
23028
|
const camera = this.camera;
|
|
21051
23029
|
const faceXSign = Math.sin(camera.theta) >= 0 ? 1 : -1;
|
|
21052
23030
|
const faceZSign = Math.cos(camera.theta) >= 0 ? 1 : -1;
|
|
@@ -21060,7 +23038,8 @@
|
|
|
21060
23038
|
|
|
21061
23039
|
const drawFace = (pts, shade) => {
|
|
21062
23040
|
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`;
|
|
21063
|
-
renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
|
|
23041
|
+
const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
|
|
23042
|
+
if (datum) this._datum(el, datum);
|
|
21064
23043
|
};
|
|
21065
23044
|
drawFace(top, 0.15);
|
|
21066
23045
|
drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
|
|
@@ -21180,7 +23159,9 @@
|
|
|
21180
23159
|
}).sort((a, b) => b.depth - a.depth);
|
|
21181
23160
|
|
|
21182
23161
|
for (const bar of bars) {
|
|
21183
|
-
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen
|
|
23162
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen, {
|
|
23163
|
+
label: bar.d.x, series: bar.d.z, x: bar.d.x, z: bar.d.z,
|
|
23164
|
+
});
|
|
21184
23165
|
}
|
|
21185
23166
|
|
|
21186
23167
|
new Legend({
|
|
@@ -21231,13 +23212,16 @@
|
|
|
21231
23212
|
const segHeight = bar.d.values[j] / stackMax;
|
|
21232
23213
|
const y0 = yCursor, y1 = yCursor + segHeight;
|
|
21233
23214
|
yCursor = y1;
|
|
21234
|
-
|
|
23215
|
+
const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
|
|
23216
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
|
|
23217
|
+
label: bar.d.x, series, x: bar.d.x, z: bar.d.z,
|
|
23218
|
+
});
|
|
21235
23219
|
}
|
|
21236
23220
|
}
|
|
21237
23221
|
|
|
21238
23222
|
const maxSegments = Math.max(...data.map(d => d.values.length));
|
|
21239
23223
|
new Legend({
|
|
21240
|
-
items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
23224
|
+
items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
21241
23225
|
x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
|
|
21242
23226
|
}).draw(this.renderer);
|
|
21243
23227
|
}
|
|
@@ -21748,6 +23732,14 @@
|
|
|
21748
23732
|
const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
|
|
21749
23733
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
21750
23734
|
this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
|
|
23735
|
+
if (typeof this.renderer.circle === 'function') {
|
|
23736
|
+
topPts.forEach((p, i) => {
|
|
23737
|
+
const d = s.values[i];
|
|
23738
|
+
this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
|
|
23739
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
23740
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
23741
|
+
});
|
|
23742
|
+
}
|
|
21751
23743
|
});
|
|
21752
23744
|
|
|
21753
23745
|
// X-axis labels.
|
|
@@ -21826,9 +23818,9 @@
|
|
|
21826
23818
|
const rOuter = innerRadius + barLen;
|
|
21827
23819
|
const color = d.color ?? palette.colorAt(i);
|
|
21828
23820
|
const path = ShapePaths.donutSlicePath(cx, cy, rOuter, innerRadius, a0, a1);
|
|
21829
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
23821
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
21830
23822
|
tooltip: `${d.label}: ${d.value}`,
|
|
21831
|
-
});
|
|
23823
|
+
}), { label: d.label });
|
|
21832
23824
|
|
|
21833
23825
|
// Category label around the outer track.
|
|
21834
23826
|
const midAngle = (a0 + a1) / 2;
|
|
@@ -21934,15 +23926,15 @@
|
|
|
21934
23926
|
|
|
21935
23927
|
// Left bar grows from center to the left.
|
|
21936
23928
|
if (leftW > 0) {
|
|
21937
|
-
this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
23929
|
+
this._datum(this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
21938
23930
|
tooltip: `${d.label} — ${leftLabel}: ${d.left}`,
|
|
21939
|
-
});
|
|
23931
|
+
}), { label: d.label, series: leftLabel });
|
|
21940
23932
|
}
|
|
21941
23933
|
// Right bar grows from center to the right.
|
|
21942
23934
|
if (rightW > 0) {
|
|
21943
|
-
this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
23935
|
+
this._datum(this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
21944
23936
|
tooltip: `${d.label} — ${rightLabel}: ${d.right}`,
|
|
21945
|
-
});
|
|
23937
|
+
}), { label: d.label, series: rightLabel });
|
|
21946
23938
|
}
|
|
21947
23939
|
|
|
21948
23940
|
// Row label centered on the axis.
|
|
@@ -22082,18 +24074,18 @@
|
|
|
22082
24074
|
const linkH = l.value * scale;
|
|
22083
24075
|
const path = this._linkPath(n.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
|
|
22084
24076
|
const { r, g, b: bl } = palette.hexToRgb(n.color);
|
|
22085
|
-
this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
|
|
24077
|
+
this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
|
|
22086
24078
|
tooltip: `${n.label} → ${t.label}: ${l.value}`,
|
|
22087
|
-
});
|
|
24079
|
+
}), { label: n.label, series: t.label });
|
|
22088
24080
|
outY += linkH;
|
|
22089
24081
|
});
|
|
22090
24082
|
});
|
|
22091
24083
|
|
|
22092
24084
|
// Draw nodes.
|
|
22093
24085
|
nodes.forEach(n => {
|
|
22094
|
-
this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
|
|
24086
|
+
this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
|
|
22095
24087
|
tooltip: `${n.label}: ${n.value}`,
|
|
22096
|
-
});
|
|
24088
|
+
}), { label: n.label });
|
|
22097
24089
|
this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
|
|
22098
24090
|
textAnchor: 'middle', fontSize: '10px', fill: '#333',
|
|
22099
24091
|
});
|
|
@@ -22233,19 +24225,19 @@
|
|
|
22233
24225
|
const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
|
|
22234
24226
|
const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
|
|
22235
24227
|
const width = Math.max(1, (l.value / maxLink) * maxStroke);
|
|
22236
|
-
this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
24228
|
+
this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
22237
24229
|
color: s.color, width,
|
|
22238
24230
|
}, {
|
|
22239
24231
|
tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
|
|
22240
|
-
});
|
|
24232
|
+
}), { label: s.label, series: t.label });
|
|
22241
24233
|
});
|
|
22242
24234
|
|
|
22243
24235
|
// Draw node arcs.
|
|
22244
24236
|
nodes.forEach(n => {
|
|
22245
24237
|
const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
|
|
22246
|
-
this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
24238
|
+
this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
22247
24239
|
tooltip: `${n.label}: ${n.value}`,
|
|
22248
|
-
});
|
|
24240
|
+
}), { label: n.label });
|
|
22249
24241
|
|
|
22250
24242
|
// Label outside the circle.
|
|
22251
24243
|
const lx = cx + (radius + 16) * Math.cos(n.mid);
|
|
@@ -22335,10 +24327,10 @@
|
|
|
22335
24327
|
if (!pos) return; // Could not place; skip.
|
|
22336
24328
|
placed.push({ x: pos.x, y: pos.y, w: box.w, h: box.h });
|
|
22337
24329
|
const color = palette.colorAt(i);
|
|
22338
|
-
this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
24330
|
+
this._datum(this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
22339
24331
|
textAnchor, fontSize: `${fontSize}px`, fill: color, fontFamily,
|
|
22340
24332
|
tooltip: `${d.text}: ${d.value}`,
|
|
22341
|
-
});
|
|
24333
|
+
}), { label: d.text });
|
|
22342
24334
|
});
|
|
22343
24335
|
}
|
|
22344
24336
|
|
|
@@ -22454,11 +24446,11 @@
|
|
|
22454
24446
|
pathData += `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`;
|
|
22455
24447
|
});
|
|
22456
24448
|
const label = row.label || (hasCategories ? row.category : null);
|
|
22457
|
-
this.renderer.path(pathData, 'none', {
|
|
24449
|
+
this._datum(this.renderer.path(pathData, 'none', {
|
|
22458
24450
|
color, width: lineWidth,
|
|
22459
24451
|
opacity: lineOpacity,
|
|
22460
24452
|
tooltip: label ? `${label}: ${dimensions.map(d => `${d}=${this._fmt(row[d])}`).join(', ')}` : undefined,
|
|
22461
|
-
});
|
|
24453
|
+
}), { label: label ?? String(rowIndex) });
|
|
22462
24454
|
});
|
|
22463
24455
|
|
|
22464
24456
|
if (legendVisible && hasCategories) {
|
|
@@ -22690,9 +24682,12 @@
|
|
|
22690
24682
|
const cellX = x + xi * cellW;
|
|
22691
24683
|
const cellY = y + yi * cellH;
|
|
22692
24684
|
const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
|
|
22693
|
-
this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
24685
|
+
const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
22694
24686
|
tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
|
|
22695
24687
|
});
|
|
24688
|
+
if (v != null) {
|
|
24689
|
+
this._datum(cellRect, { label: yc, series: xc, x: xc, y: yc });
|
|
24690
|
+
}
|
|
22696
24691
|
if (showValues && v != null) {
|
|
22697
24692
|
this.renderer.text(Axis.formatValue(v), cellX + cellW / 2, cellY + cellH / 2, {
|
|
22698
24693
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: ColorPalette.isDark(color) ? '#fff' : '#333',
|
|
@@ -56114,6 +58109,39 @@ ${theirs}` : theirs;
|
|
|
56114
58109
|
const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
|
|
56115
58110
|
back?.addEventListener?.('click', () => this._drawResultTable(view, options));
|
|
56116
58111
|
if (back?.style) back.style.cursor = 'pointer';
|
|
58112
|
+
const choices = view.chartChoices();
|
|
58113
|
+
if (choices.length > 1) {
|
|
58114
|
+
const pick = this.renderer.text(`${choice.label} ▾`, 68, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
|
|
58115
|
+
const show = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
|
|
58116
|
+
pick?.addEventListener?.('click', (e) => show(e.clientX, e.clientY, choices.map(c => ({
|
|
58117
|
+
label: `${c.label}${c.key === choice.key ? ' ✓' : ''}`,
|
|
58118
|
+
action: () => this._drawResultChart(view, c, options),
|
|
58119
|
+
}))));
|
|
58120
|
+
if (pick?.style) pick.style.cursor = 'pointer';
|
|
58121
|
+
}
|
|
58122
|
+
}
|
|
58123
|
+
|
|
58124
|
+
/**
|
|
58125
|
+
* Mounts an interactive `AskBar` (question input, configurable next-word prediction in a
|
|
58126
|
+
* scrollable list, semantic sense/reference/relation badges, and sample query pills) into
|
|
58127
|
+
* `container` (or links `resultEngine: this` when called as `engine.askBar(container, options)`).
|
|
58128
|
+
* @param {HTMLElement|string|object} containerOrOptions host container element/id, or options when mounting into `this.container`
|
|
58129
|
+
* @param {object} [options] `AskBar` / `AskView` options (`suggest`, `measures`, `dimensions`, `members`, `sampleQuestions`, `visibleCount`, `predictChoices`, `onAsk`, etc.)
|
|
58130
|
+
* @returns {AskBar}
|
|
58131
|
+
*/
|
|
58132
|
+
askBar(containerOrOptions, options = {}) {
|
|
58133
|
+
const isPlainOptions = containerOrOptions
|
|
58134
|
+
&& typeof containerOrOptions === 'object'
|
|
58135
|
+
&& !containerOrOptions.nodeType
|
|
58136
|
+
&& typeof containerOrOptions.appendChild !== 'function';
|
|
58137
|
+
const container = isPlainOptions ? this.container : containerOrOptions;
|
|
58138
|
+
const opts = isPlainOptions ? containerOrOptions : options;
|
|
58139
|
+
return new AskBar(container, { resultEngine: this, ...opts });
|
|
58140
|
+
}
|
|
58141
|
+
|
|
58142
|
+
/** Static helper: `RenderEngine.askBar(container, options)` constructs and mounts an `AskBar`. */
|
|
58143
|
+
static askBar(container, options = {}) {
|
|
58144
|
+
return new AskBar(container, options);
|
|
56117
58145
|
}
|
|
56118
58146
|
|
|
56119
58147
|
/**
|
|
@@ -56164,7 +58192,8 @@ ${theirs}` : theirs;
|
|
|
56164
58192
|
pivotChart(view, options = {}) {
|
|
56165
58193
|
if (!this.renderer) return null;
|
|
56166
58194
|
const chart = new PivotChart(view, options);
|
|
56167
|
-
chart.
|
|
58195
|
+
chart.redraw = () => this._drawPivotChart(chart, options);
|
|
58196
|
+
chart.detach = view.listen(chart.redraw);
|
|
56168
58197
|
this._drawPivotChart(chart, options);
|
|
56169
58198
|
return chart;
|
|
56170
58199
|
}
|
|
@@ -56175,15 +58204,19 @@ ${theirs}` : theirs;
|
|
|
56175
58204
|
if (choice === null) {
|
|
56176
58205
|
this.renderer.clear();
|
|
56177
58206
|
this.renderer.text('No chart fits what the grid shows', 8, 14, { fill: '#666', fontSize: '12px', verticalAlign: 'middle' });
|
|
58207
|
+
options.onDraw?.(null, chart);
|
|
56178
58208
|
return;
|
|
56179
58209
|
}
|
|
56180
58210
|
// No title unless one is given: the grid beside it says what is charted, and the link below
|
|
56181
58211
|
// names the chart (with the chooser's reason as its tooltip).
|
|
56182
58212
|
this.renderChartType(choice.key, choice.data, {
|
|
58213
|
+
...choice.options,
|
|
56183
58214
|
title: options.title,
|
|
56184
58215
|
legend: true, // a series per column member: which colour is which must be said
|
|
56185
58216
|
onDatumClick: (datum) => chart.click(datum),
|
|
58217
|
+
onNodeClick: (node) => chart.click({ label: node.label }),
|
|
56186
58218
|
});
|
|
58219
|
+
options.onDraw?.(choice, chart);
|
|
56187
58220
|
// Which chart, and the menu to change it, drawn over the chart as resultView's "◂ Table" is.
|
|
56188
58221
|
if (!this.renderer.supportsSceneGraph) return;
|
|
56189
58222
|
const pick = this.renderer.text(`${choice.label} ▾`, 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
|
|
@@ -56201,6 +58234,57 @@ ${theirs}` : theirs;
|
|
|
56201
58234
|
this.fitToContainer();
|
|
56202
58235
|
}
|
|
56203
58236
|
|
|
58237
|
+
/**
|
|
58238
|
+
* Renders an icicle chart — rectangular hierarchical partition chart (top-down bands by depth,
|
|
58239
|
+
* width proportional to value within each parent's span).
|
|
58240
|
+
* @param {Array<{label: string, value?: number, children?: Array}>} data
|
|
58241
|
+
* @param {object} [options]
|
|
58242
|
+
*/
|
|
58243
|
+
icicleChart(data, options = {}) {
|
|
58244
|
+
if (!this.renderer) return;
|
|
58245
|
+
this._chart3D = false;
|
|
58246
|
+
this._chartState = { type: 'icicle', data, options };
|
|
58247
|
+
this._renderChart(new IcicleChart(this.renderer, data, options));
|
|
58248
|
+
}
|
|
58249
|
+
|
|
58250
|
+
/**
|
|
58251
|
+
* Renders a Pareto chart — categories sorted descending with a cumulative percentage line (0–100%)
|
|
58252
|
+
* on a right-hand axis and an 80% cutoff reference line.
|
|
58253
|
+
* @param {Array<{label: string, value: number}>} data
|
|
58254
|
+
* @param {object} [options]
|
|
58255
|
+
*/
|
|
58256
|
+
paretoChart(data, options = {}) {
|
|
58257
|
+
if (!this.renderer) return;
|
|
58258
|
+
this._chart3D = false;
|
|
58259
|
+
this._chartState = { type: 'pareto', data, options };
|
|
58260
|
+
this._renderChart(new ParetoChart(this.renderer, data, options));
|
|
58261
|
+
}
|
|
58262
|
+
|
|
58263
|
+
/**
|
|
58264
|
+
* Renders a lollipop chart — stem-and-dot alternative to bars for 1D or multi-series comparisons.
|
|
58265
|
+
* @param {Array<{label: string, value: number}>|Array<{name: string, values: Array<{label: string, value: number}>}>} data
|
|
58266
|
+
* @param {object} [options]
|
|
58267
|
+
*/
|
|
58268
|
+
lollipopChart(data, options = {}) {
|
|
58269
|
+
if (!this.renderer) return;
|
|
58270
|
+
this._chart3D = false;
|
|
58271
|
+
this._chartState = { type: 'lollipop', data, options };
|
|
58272
|
+
this._renderChart(new LollipopChart(this.renderer, data, options));
|
|
58273
|
+
}
|
|
58274
|
+
|
|
58275
|
+
/**
|
|
58276
|
+
* Renders a Marimekko (mosaic) chart — variable-width 100%-stacked columns where column width is
|
|
58277
|
+
* proportional to each category's total and segment height is each series' share within that category.
|
|
58278
|
+
* @param {Array<{name: string, values: Array<{label: string, value: number}>}>|Array<{label: string, values: number[]}>} data
|
|
58279
|
+
* @param {object} [options]
|
|
58280
|
+
*/
|
|
58281
|
+
marimekkoChart(data, options = {}) {
|
|
58282
|
+
if (!this.renderer) return;
|
|
58283
|
+
this._chart3D = false;
|
|
58284
|
+
this._chartState = { type: 'marimekko', data, options };
|
|
58285
|
+
this._renderChart(new MarimekkoChart(this.renderer, data, options));
|
|
58286
|
+
}
|
|
58287
|
+
|
|
56204
58288
|
/**
|
|
56205
58289
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
56206
58290
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -61007,6 +63091,8 @@ th { color: #555; font-weight: 500; }
|
|
|
61007
63091
|
exports.ArchimateView = ArchimateView;
|
|
61008
63092
|
exports.Arrangement = Arrangement;
|
|
61009
63093
|
exports.ArrowMarker = ArrowMarker;
|
|
63094
|
+
exports.AskBar = AskBar;
|
|
63095
|
+
exports.AskView = AskView;
|
|
61010
63096
|
exports.AssociationKind = AssociationKind;
|
|
61011
63097
|
exports.AttributedLevel = AttributedLevel;
|
|
61012
63098
|
exports.AzureDevOpsApi = AzureDevOpsApi;
|
|
@@ -61145,6 +63231,7 @@ th { color: #555; font-weight: 500; }
|
|
|
61145
63231
|
exports.ICON_DEFS = ICON_DEFS;
|
|
61146
63232
|
exports.ITElementType = ITElementType;
|
|
61147
63233
|
exports.ITIconNotation = ITIconNotation;
|
|
63234
|
+
exports.IcicleChart = IcicleChart;
|
|
61148
63235
|
exports.IconSet = IconSet;
|
|
61149
63236
|
exports.ImageSelector = ImageSelector;
|
|
61150
63237
|
exports.ItemGroup = ItemGroup;
|
|
@@ -61162,6 +63249,7 @@ th { color: #555; font-weight: 500; }
|
|
|
61162
63249
|
exports.LocalJsonStore = LocalJsonStore;
|
|
61163
63250
|
exports.LockCommand = LockCommand;
|
|
61164
63251
|
exports.LogicalLevel = LogicalLevel;
|
|
63252
|
+
exports.LollipopChart = LollipopChart;
|
|
61165
63253
|
exports.LowerCommand = LowerCommand;
|
|
61166
63254
|
exports.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS;
|
|
61167
63255
|
exports.MIDNIGHT_THEME = MIDNIGHT_THEME;
|
|
@@ -61169,6 +63257,7 @@ th { color: #555; font-weight: 500; }
|
|
|
61169
63257
|
exports.MIN_ROW_SPAN = MIN_ROW_SPAN;
|
|
61170
63258
|
exports.MODEL_ELEMENT_MIME = MODEL_ELEMENT_MIME;
|
|
61171
63259
|
exports.ManualInputTemplate = ManualInputTemplate;
|
|
63260
|
+
exports.MarimekkoChart = MarimekkoChart;
|
|
61172
63261
|
exports.Mat4 = Mat4;
|
|
61173
63262
|
exports.MeasuredCategory = MeasuredCategory;
|
|
61174
63263
|
exports.MenuBar = MenuBar;
|
|
@@ -61198,6 +63287,7 @@ th { color: #555; font-weight: 500; }
|
|
|
61198
63287
|
exports.PackageTemplate = PackageTemplate;
|
|
61199
63288
|
exports.Palette = Palette;
|
|
61200
63289
|
exports.ParallelogramTemplate = ParallelogramTemplate;
|
|
63290
|
+
exports.ParetoChart = ParetoChart;
|
|
61201
63291
|
exports.Part = Part;
|
|
61202
63292
|
exports.PartFactory = PartFactory;
|
|
61203
63293
|
exports.PartNode = PartNode;
|