@peter.prib/renderengine 0.13.0 → 0.15.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 +480 -302
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49217 -37484
- package/dist/renderengine.umd.js +24899 -13093
- package/documentation/REFERENCE.md +68 -1
- package/package.json +13 -3
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +269 -3
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +7 -2
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +142 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +69 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +6 -4
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +25 -3
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +107 -4
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +5 -2
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +6 -3
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +12 -5
- package/src/charting/charts/RadialBarChart.js +6 -3
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +129 -6
- package/src/charting/charts/ScatterChart.js +19 -3
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +125 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
- package/src/charting/charts/StreamGraphChart.js +18 -3
- package/src/charting/charts/SunburstChart.js +28 -8
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +31 -11
- package/src/charting/charts/ViolinChart.js +20 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +310 -13
- package/src/charting/pivot/PivotGridChart.js +322 -34
- package/src/charting/pivot/PivotView.js +626 -21
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2899
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
2
|
+
import { Chart } from '../Chart.js';
|
|
3
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
4
|
+
import { ColorPalette } from '../ColorPalette.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SparklineTableChart — executive multi-metric table embedding inline
|
|
8
|
+
* sparklines, trend change badges, and bullet KPI performance bars per row.
|
|
9
|
+
*
|
|
10
|
+
* Data shape:
|
|
11
|
+
* Array<{
|
|
12
|
+
* label: string,
|
|
13
|
+
* value: number,
|
|
14
|
+
* target?: number,
|
|
15
|
+
* sparkline?: number[],
|
|
16
|
+
* delta?: number,
|
|
17
|
+
* status?: 'good'|'warn'|'bad'
|
|
18
|
+
* }>
|
|
19
|
+
*/
|
|
20
|
+
export class SparklineTableChart extends Chart {
|
|
21
|
+
static chartType = 'sparklineTable';
|
|
22
|
+
|
|
23
|
+
_render() {
|
|
24
|
+
const data = this.data;
|
|
25
|
+
if (!Array.isArray(data) || !data.length) return;
|
|
26
|
+
|
|
27
|
+
const options = this.options;
|
|
28
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
29
|
+
const {
|
|
30
|
+
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
31
|
+
title, rowHeight = 44, colors,
|
|
32
|
+
} = options;
|
|
33
|
+
const plotBox = { x, y, width, height };
|
|
34
|
+
|
|
35
|
+
new ChartLabels({ title }).draw(this.renderer, plotBox);
|
|
36
|
+
|
|
37
|
+
const palette = new ColorPalette(colors);
|
|
38
|
+
const totalRows = data.length;
|
|
39
|
+
const headerH = 26;
|
|
40
|
+
const startY = y + headerH;
|
|
41
|
+
|
|
42
|
+
// Column widths
|
|
43
|
+
const colLabelW = Math.max(90, width * 0.22);
|
|
44
|
+
const colValueW = Math.max(65, width * 0.16);
|
|
45
|
+
const colTrendW = Math.max(65, width * 0.15);
|
|
46
|
+
const colSparkW = Math.max(100, width * 0.24);
|
|
47
|
+
const colBulletW = Math.max(80, width - colLabelW - colValueW - colTrendW - colSparkW);
|
|
48
|
+
|
|
49
|
+
const colLabelX = x;
|
|
50
|
+
const colValueX = colLabelX + colLabelW;
|
|
51
|
+
const colTrendX = colValueX + colValueW;
|
|
52
|
+
const colSparkX = colTrendX + colTrendW;
|
|
53
|
+
const colBulletX = colSparkX + colSparkW;
|
|
54
|
+
|
|
55
|
+
// Table Header
|
|
56
|
+
this.renderer.rect(x, y, width, headerH, 'rgba(0,0,0,0.03)', { color: '#e0e0e0', width: 1 });
|
|
57
|
+
const textH = y + headerH / 2;
|
|
58
|
+
this.renderer.text('Metric / Entity', colLabelX + 8, textH, { textAnchor: 'start', verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: '#666' });
|
|
59
|
+
this.renderer.text('Current', colValueX + colValueW - 8, textH, { textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: '#666' });
|
|
60
|
+
this.renderer.text('Change', colTrendX + colTrendW / 2, textH, { textAnchor: this.renderer.centerTextAnchor, verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: '#666' });
|
|
61
|
+
this.renderer.text('History', colSparkX + colSparkW / 2, textH, { textAnchor: this.renderer.centerTextAnchor, verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: '#666' });
|
|
62
|
+
this.renderer.text('Target Performance', colBulletX + 8, textH, { textAnchor: 'start', verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: '#666' });
|
|
63
|
+
|
|
64
|
+
// Global max for bullet bars
|
|
65
|
+
const allTargetsAndValues = data.flatMap(d => {
|
|
66
|
+
const v = Number(d.value ?? d.current);
|
|
67
|
+
const t = Number(d.target ?? v);
|
|
68
|
+
return [Number.isFinite(v) ? v : 0, Number.isFinite(t) ? t : 0];
|
|
69
|
+
});
|
|
70
|
+
const maxBulletVal = Math.max(...allTargetsAndValues, 1);
|
|
71
|
+
|
|
72
|
+
data.forEach((row, i) => {
|
|
73
|
+
const rowY = startY + i * rowHeight;
|
|
74
|
+
if (rowY + rowHeight > y + height) return;
|
|
75
|
+
|
|
76
|
+
// Zebra striping
|
|
77
|
+
const bg = i % 2 === 0 ? '#ffffff' : 'rgba(0,0,0,0.015)';
|
|
78
|
+
this.renderer.rect(x, rowY, width, rowHeight, bg, { color: '#f0f0f0', width: 1 });
|
|
79
|
+
const midY = rowY + rowHeight / 2;
|
|
80
|
+
|
|
81
|
+
// 1. Label
|
|
82
|
+
const label = String(row.label ?? row.metric ?? row.kpi ?? row.name ?? `Metric ${i + 1}`);
|
|
83
|
+
this.renderer.text(label, colLabelX + 8, midY, {
|
|
84
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fontWeight: '600', fill: '#222',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 2. Current Value
|
|
88
|
+
const rawVal = row.value ?? row.current;
|
|
89
|
+
const valStr = rawVal != null ? (typeof rawVal === 'string' ? rawVal : Axis.formatValue(rawVal)) : '';
|
|
90
|
+
this.renderer.text(valStr, colValueX + colValueW - 8, midY, {
|
|
91
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '12px', fontWeight: 'bold', fill: '#111',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 3. Trend Delta Badge
|
|
95
|
+
const history = Array.isArray(row.sparkline) ? row.sparkline : (Array.isArray(row.trend) ? row.trend : (Array.isArray(row.history) ? row.history : null));
|
|
96
|
+
const delta = row.delta ?? (typeof row.change === 'number' ? row.change : null) ?? (history?.length > 1 ? history[history.length - 1] - history[0] : null);
|
|
97
|
+
const deltaStr = typeof row.change === 'string' ? row.change : (delta != null ? `${delta >= 0 ? '+' : ''}${Axis.formatValue(delta)}` : null);
|
|
98
|
+
if (deltaStr != null) {
|
|
99
|
+
const isUp = delta != null ? delta >= 0 : !deltaStr.startsWith('-');
|
|
100
|
+
const badgeColor = isUp ? '#27ae60' : '#e74c3c';
|
|
101
|
+
const badgeBg = isUp ? 'rgba(39, 174, 96, 0.12)' : 'rgba(231, 76, 60, 0.12)';
|
|
102
|
+
const badgeW = 54, badgeH = 18;
|
|
103
|
+
const badgeX = colTrendX + (colTrendW - badgeW) / 2;
|
|
104
|
+
const badgeY = midY - badgeH / 2;
|
|
105
|
+
|
|
106
|
+
this.renderer.rect(badgeX, badgeY, badgeW, badgeH, badgeBg, { color: badgeColor, width: 1 }, { rx: 3, ry: 3 });
|
|
107
|
+
this.renderer.text(deltaStr, colTrendX + colTrendW / 2, midY, {
|
|
108
|
+
textAnchor: this.renderer.centerTextAnchor, verticalAlign: 'middle', fontSize: '10px', fontWeight: 'bold', fill: badgeColor,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. Inline Sparkline
|
|
113
|
+
if (history && history.length > 1) {
|
|
114
|
+
const sPadX = 10, sPadY = 8;
|
|
115
|
+
const sw = colSparkW - sPadX * 2;
|
|
116
|
+
const sh = rowHeight - sPadY * 2;
|
|
117
|
+
const sx = colSparkX + sPadX;
|
|
118
|
+
const sy = rowY + sPadY;
|
|
119
|
+
|
|
120
|
+
const sMin = Math.min(...history);
|
|
121
|
+
const sMax = Math.max(...history);
|
|
122
|
+
const sRange = (sMax - sMin) || 1;
|
|
123
|
+
const sStep = sw / (history.length - 1);
|
|
124
|
+
|
|
125
|
+
const pts = history.map((v, idx) => ({
|
|
126
|
+
px: sx + idx * sStep,
|
|
127
|
+
py: sy + sh - ((v - sMin) / sRange) * sh,
|
|
128
|
+
}));
|
|
129
|
+
const sparkD = 'M' + pts.map(p => `${p.px.toFixed(1)},${p.py.toFixed(1)}`).join(' L');
|
|
130
|
+
const sparkColor = palette.colorAt(i);
|
|
131
|
+
this.renderer.path(sparkD, 'none', { color: sparkColor, width: 1.6, 'stroke-linecap': 'round' });
|
|
132
|
+
|
|
133
|
+
// Endpoint accent dot
|
|
134
|
+
const last = pts[pts.length - 1];
|
|
135
|
+
this.renderer.circle(last.px, last.py, 2.5, sparkColor, { color: '#ffffff', width: 1 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 5. Bullet Performance Bar
|
|
139
|
+
const bPadX = 8;
|
|
140
|
+
const bw = Math.max(10, colBulletW - bPadX * 2);
|
|
141
|
+
const bh = 14;
|
|
142
|
+
const bx = colBulletX + bPadX;
|
|
143
|
+
const by = midY - bh / 2;
|
|
144
|
+
|
|
145
|
+
// Range background track
|
|
146
|
+
this.renderer.rect(bx, by, bw, bh, '#e8ecef', null, { rx: 2, ry: 2 });
|
|
147
|
+
// Qualitative 70% threshold band
|
|
148
|
+
this.renderer.rect(bx, by, bw * 0.7, bh, '#d5dbdb', null);
|
|
149
|
+
|
|
150
|
+
// Performance value bar
|
|
151
|
+
const numVal = Number(row.value ?? row.current);
|
|
152
|
+
const valRatio = Number.isFinite(numVal) ? Math.min(1, Math.max(0, numVal / maxBulletVal)) : 0.5;
|
|
153
|
+
const barW = Math.max(2, valRatio * bw);
|
|
154
|
+
const barColor = row.status === 'bad' || row.status === 'Attention' ? '#e74c3c' : (row.status === 'warn' ? '#f39c12' : '#2c3e50');
|
|
155
|
+
this.renderer.rect(bx, by + 3, barW, bh - 6, barColor, null);
|
|
156
|
+
|
|
157
|
+
// Target marker line
|
|
158
|
+
const numTarget = Number(row.target);
|
|
159
|
+
if (Number.isFinite(numTarget)) {
|
|
160
|
+
const targetRatio = Math.min(1, Math.max(0, numTarget / maxBulletVal));
|
|
161
|
+
const targetX = bx + targetRatio * bw;
|
|
162
|
+
this.renderer.path(`M${targetX},${by - 2} L${targetX},${by + bh + 2}`, 'none', { color: '#e74c3c', width: 2.2 });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -1,35 +1,149 @@
|
|
|
1
1
|
import { Axis } from '../Axis.js';
|
|
2
|
+
import { Legend } from '../Legend.js';
|
|
2
3
|
import { BarChartBase } from './BarChartBase.js';
|
|
4
|
+
import { TableChart } from './TableChart.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* StackedBarChart — vertical stacked bar chart.
|
|
8
|
+
*
|
|
9
|
+
* Accepts either:
|
|
10
|
+
* - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (the same
|
|
11
|
+
* shape as `GroupedBarChart`, `LineChart` and `StackedAreaChart`), or
|
|
12
|
+
* - Flat per-category stacks `Array<{label: string, values: number[]}>` with optional
|
|
13
|
+
* `options.seriesLabels`.
|
|
14
|
+
*
|
|
15
|
+
* Draws a numeric Y axis (`_drawYAxis`), optional series legend (`legend: true`), and records each
|
|
16
|
+
* segment with `this._datum()` so `onDatumClick` / `PivotChart` drilling works on every segment.
|
|
6
17
|
*/
|
|
7
18
|
export class StackedBarChart extends BarChartBase {
|
|
8
19
|
static chartType = 'stackedBar';
|
|
9
20
|
|
|
21
|
+
static _normalize(data, seriesLabels = null) {
|
|
22
|
+
if (!Array.isArray(data) || data.length === 0) return { categories: [], seriesNames: [] };
|
|
23
|
+
if (Array.isArray(data[0]?.values) && typeof data[0].values[0] === 'object' && data[0].values[0] !== null) {
|
|
24
|
+
const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
|
|
25
|
+
const maps = data.map(s => new Map(s.values.map(v => [v.label, v.value])));
|
|
26
|
+
return {
|
|
27
|
+
seriesNames: data.map((s, i) => s.name ?? seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
28
|
+
categories: labels.map(label => ({ label, values: maps.map(m => m.get(label) ?? 0) })),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const maxLen = Math.max(0, ...data.map(d => d.values?.length ?? 0));
|
|
32
|
+
return {
|
|
33
|
+
seriesNames: Array.from({ length: maxLen }, (_, i) => seriesLabels?.[i] ?? `Series ${i + 1}`),
|
|
34
|
+
categories: data.map(d => ({ label: d.label, values: (d.values ?? []).map(v => v ?? 0) })),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_setup() {
|
|
39
|
+
this._normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
|
|
40
|
+
super._setup();
|
|
41
|
+
if (this.options.legend && this.options.width === undefined && this._normalized.seriesNames.length > 0) {
|
|
42
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_legendOptions() {
|
|
47
|
+
return {
|
|
48
|
+
items: this._normalized.seriesNames.map((name, i) => ({
|
|
49
|
+
label: name,
|
|
50
|
+
color: this._palette.colorAt(i),
|
|
51
|
+
hidden: this._dataFilter.has(i),
|
|
52
|
+
dataIndex: i,
|
|
53
|
+
})),
|
|
54
|
+
itemHeight: 20, markerSize: 10,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_isNormalized() {
|
|
59
|
+
return Boolean(this.options.normalize || this.options.stackMode === 'percent');
|
|
60
|
+
}
|
|
61
|
+
|
|
10
62
|
_computeMaxValue() {
|
|
11
|
-
|
|
12
|
-
|
|
63
|
+
if (this._isNormalized()) {
|
|
64
|
+
return this._isHorizontal() ? this._drawXAxis(100) : this._drawYAxis(100);
|
|
65
|
+
}
|
|
66
|
+
const { categories } = this._normalized;
|
|
67
|
+
const rawMax = Math.max(0, ...categories.map(d => d.values.reduce((sum, val, idx) => sum + (this._dataFilter.has(idx) ? 0 : Math.max(0, val)), 0))) || 1;
|
|
68
|
+
return this._isHorizontal() ? this._drawXAxis(rawMax) : this._drawYAxis(rawMax);
|
|
13
69
|
}
|
|
14
70
|
|
|
15
71
|
_renderBars(maxStackValue) {
|
|
16
|
-
const
|
|
72
|
+
const { categories, seriesNames } = this._normalized;
|
|
73
|
+
if (categories.length === 0) return;
|
|
17
74
|
const { x, y, width, height } = this._plotBox;
|
|
18
|
-
const
|
|
19
|
-
const
|
|
75
|
+
const normalize = this._isNormalized();
|
|
76
|
+
const labels = categories.map(d => d.label);
|
|
77
|
+
const { bandWidth, margin } = this._bandScale(categories.length);
|
|
20
78
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
21
79
|
|
|
22
|
-
|
|
80
|
+
if (this._isHorizontal()) {
|
|
81
|
+
categories.forEach((d, i) => {
|
|
82
|
+
let currentX = x;
|
|
83
|
+
const barY = y + i * (bandWidth + margin);
|
|
84
|
+
const colTotal = d.values.reduce((sum, val, idx) => sum + (this._dataFilter.has(idx) ? 0 : Math.max(0, val ?? 0)), 0);
|
|
85
|
+
d.values.forEach((value, j) => {
|
|
86
|
+
if (this._dataFilter.has(j)) return;
|
|
87
|
+
if (value == null || value <= 0) return;
|
|
88
|
+
const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
|
|
89
|
+
const barWidth = (plotted / maxStackValue) * width;
|
|
90
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
91
|
+
const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
|
|
92
|
+
this._datum(this.renderer.rect(currentX, barY, barWidth, bandWidth, this._palette.colorAt(j), null, {
|
|
93
|
+
tooltip: `${d.label} - ${series}: ${tipVal}`,
|
|
94
|
+
}), { label: d.label, series });
|
|
95
|
+
if (this.options.showValues && barWidth > 20 && bandWidth > 12) {
|
|
96
|
+
const valText = typeof this.options.format === 'function' ? this.options.format(value) : (normalize ? `${plotted.toFixed(0)}%` : String(value));
|
|
97
|
+
this.renderer.text(valText, currentX + barWidth / 2, barY + bandWidth / 2, {
|
|
98
|
+
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fill: '#ffffff', 'pointer-events': 'none',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
currentX += barWidth;
|
|
102
|
+
});
|
|
103
|
+
this.renderer.text(d.label, x - 8, barY + bandWidth / 2, {
|
|
104
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#333',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
111
|
+
const room = (bandWidth + margin) - 4;
|
|
112
|
+
const cut = room >= measure('x'.repeat(6));
|
|
113
|
+
const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
|
|
114
|
+
|
|
115
|
+
categories.forEach((d, i) => {
|
|
23
116
|
let currentY = y + height;
|
|
24
117
|
const barX = x + i * (bandWidth + margin);
|
|
118
|
+
const colTotal = d.values.reduce((sum, val, idx) => sum + (this._dataFilter.has(idx) ? 0 : Math.max(0, val ?? 0)), 0);
|
|
25
119
|
d.values.forEach((value, j) => {
|
|
26
|
-
|
|
120
|
+
if (this._dataFilter.has(j)) return;
|
|
121
|
+
if (value == null || value <= 0) return;
|
|
122
|
+
const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
|
|
123
|
+
const barHeight = (plotted / maxStackValue) * height;
|
|
27
124
|
currentY -= barHeight;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
125
|
+
const series = seriesNames[j] ?? `Series ${j + 1}`;
|
|
126
|
+
const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
|
|
127
|
+
this._datum(this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
|
|
128
|
+
tooltip: `${d.label} - ${series}: ${tipVal}`,
|
|
129
|
+
}), { label: d.label, series });
|
|
130
|
+
if (this.options.showValues && barHeight > 14 && bandWidth > 18) {
|
|
131
|
+
const valText = typeof this.options.format === 'function' ? this.options.format(value) : (normalize ? `${plotted.toFixed(0)}%` : String(value));
|
|
132
|
+
this.renderer.text(valText, barX + bandWidth / 2, currentY + barHeight / 2, {
|
|
133
|
+
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fill: '#ffffff', 'pointer-events': 'none',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
31
136
|
});
|
|
32
|
-
if (i % labelStep === 0)
|
|
137
|
+
if (i % labelStep === 0) {
|
|
138
|
+
this.renderer.text(cut ? TableChart.fit(String(d.label), room, measure) : d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
139
|
+
}
|
|
33
140
|
});
|
|
34
141
|
}
|
|
142
|
+
|
|
143
|
+
_afterBars() {
|
|
144
|
+
const { x, y, width } = this._plotBox;
|
|
145
|
+
if (this.options.legend && this._normalized.seriesNames.length > 0) {
|
|
146
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
35
149
|
}
|
|
@@ -16,7 +16,8 @@ export class StackedBarChart3DOrbit extends Chart3D {
|
|
|
16
16
|
const { x: bx, y: by, width, height } = box;
|
|
17
17
|
|
|
18
18
|
const { xLabels, zLabels, xIndex, zIndex } = this.bucketGrid(data);
|
|
19
|
-
const
|
|
19
|
+
const normalize = Boolean(options.normalize || options.stackMode === 'percent');
|
|
20
|
+
const stackMax = normalize ? 100 : (Math.max(...data.map(d => d.values.reduce((s, v) => s + v, 0))) || 1);
|
|
20
21
|
const palette = new ColorPalette(colors);
|
|
21
22
|
|
|
22
23
|
const cellW = 2 / Math.max(1, xLabels.length);
|
|
@@ -32,24 +33,31 @@ export class StackedBarChart3DOrbit extends Chart3D {
|
|
|
32
33
|
const xCenter = -1 + cellW * (xi + 0.5);
|
|
33
34
|
const zCenter = -1 + cellD * (zi + 0.5);
|
|
34
35
|
const hw = cellW * barFrac / 2, hd = cellD * barFrac / 2;
|
|
35
|
-
const
|
|
36
|
+
const colTotal = d.values.reduce((s, v) => s + Math.max(0, v), 0) || 1;
|
|
37
|
+
const stackTotal = normalize ? 1 : colTotal / stackMax;
|
|
36
38
|
const { depth } = this.project(xCenter, stackTotal / 2, zCenter);
|
|
37
|
-
return { d, x0: xCenter - hw, x1: xCenter + hw, z0: zCenter - hd, z1: zCenter + hd, depth };
|
|
39
|
+
return { d, colTotal, x0: xCenter - hw, x1: xCenter + hw, z0: zCenter - hd, z1: zCenter + hd, depth };
|
|
38
40
|
}).sort((a, b) => b.depth - a.depth);
|
|
39
41
|
|
|
40
42
|
for (const bar of bars) {
|
|
41
43
|
let yCursor = 0;
|
|
42
44
|
for (let j = 0; j < bar.d.values.length; j++) {
|
|
43
|
-
const
|
|
45
|
+
const rawVal = bar.d.values[j] ?? 0;
|
|
46
|
+
const plotted = normalize ? (rawVal / bar.colTotal) * 100 : rawVal;
|
|
47
|
+
const segHeight = plotted / stackMax;
|
|
44
48
|
const y0 = yCursor, y1 = yCursor + segHeight;
|
|
45
49
|
yCursor = y1;
|
|
46
|
-
|
|
50
|
+
const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
|
|
51
|
+
const tipVal = normalize ? `${((rawVal / bar.colTotal) * 100).toFixed(1)}% (${rawVal})` : `${rawVal}`;
|
|
52
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
|
|
53
|
+
label: bar.d.x, series, x: bar.d.x, z: bar.d.z, tooltip: `${bar.d.x} / ${bar.d.z} — ${series}: ${tipVal}`,
|
|
54
|
+
});
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
const maxSegments = Math.max(...data.map(d => d.values.length));
|
|
51
59
|
new Legend({
|
|
52
|
-
items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
60
|
+
items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
53
61
|
x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
|
|
54
62
|
}).draw(this.renderer);
|
|
55
63
|
}
|
|
@@ -69,7 +69,8 @@ export class StreamGraphChart extends Chart {
|
|
|
69
69
|
|
|
70
70
|
// Compute totals per point and max extent for scaling.
|
|
71
71
|
const totals = new Array(pointCount).fill(0);
|
|
72
|
-
series.forEach(s => {
|
|
72
|
+
series.forEach((s, si) => {
|
|
73
|
+
if (this._dataFilter.has(si)) return;
|
|
73
74
|
s.values.forEach((d, i) => { totals[i] += d.value; });
|
|
74
75
|
});
|
|
75
76
|
const maxExtent = Math.max(...totals.map(v => Math.abs(v))) || 1;
|
|
@@ -81,7 +82,7 @@ export class StreamGraphChart extends Chart {
|
|
|
81
82
|
const total = totals[i];
|
|
82
83
|
let y0 = -total / 2;
|
|
83
84
|
series.forEach((s, si) => {
|
|
84
|
-
const v = s.values[i].value;
|
|
85
|
+
const v = this._dataFilter.has(si) ? 0 : s.values[i].value;
|
|
85
86
|
bands[si][i] = { y0, y1: y0 + v };
|
|
86
87
|
y0 += v;
|
|
87
88
|
});
|
|
@@ -102,12 +103,21 @@ export class StreamGraphChart extends Chart {
|
|
|
102
103
|
|
|
103
104
|
// Draw layers bottom-up so overlaps look correct.
|
|
104
105
|
series.forEach((s, si) => {
|
|
106
|
+
if (this._dataFilter.has(si)) return;
|
|
105
107
|
const color = palette.colorAt(si);
|
|
106
108
|
const topPts = bands[si].map((b, i) => ({ x: x + i * pointMargin, y: toY(b.y1) }));
|
|
107
109
|
const bottomPts = bands[si].map((b, i) => ({ x: x + i * pointMargin, y: toY(b.y0) }));
|
|
108
110
|
const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
|
|
109
111
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
110
112
|
this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
|
|
113
|
+
if (typeof this.renderer.circle === 'function') {
|
|
114
|
+
topPts.forEach((p, i) => {
|
|
115
|
+
const d = s.values[i];
|
|
116
|
+
this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
|
|
117
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
118
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
111
121
|
});
|
|
112
122
|
|
|
113
123
|
// X-axis labels.
|
|
@@ -124,7 +134,12 @@ export class StreamGraphChart extends Chart {
|
|
|
124
134
|
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: '#999', width: 1 });
|
|
125
135
|
|
|
126
136
|
if (isMultiline && showLegend) {
|
|
127
|
-
const legendItems = series.map((s, i) => ({
|
|
137
|
+
const legendItems = series.map((s, i) => ({
|
|
138
|
+
label: s.name,
|
|
139
|
+
color: palette.colorAt(i),
|
|
140
|
+
hidden: this._dataFilter.has(i),
|
|
141
|
+
dataIndex: i,
|
|
142
|
+
}));
|
|
128
143
|
Legend.beside({ x, y, width }, this.renderer.width, {
|
|
129
144
|
items: legendItems,
|
|
130
145
|
itemHeight: 20, markerSize: 12,
|
|
@@ -6,16 +6,17 @@ import { HierarchyChart } from './HierarchyChart.js';
|
|
|
6
6
|
* SunburstChart — the same hierarchical composition a treemap shows as nested
|
|
7
7
|
* rectangles, drawn instead as nested rings (one ring per depth), each ring's
|
|
8
8
|
* arcs sized by value like a pie chart.
|
|
9
|
+
* Supports breadcrumb drill-down.
|
|
9
10
|
*/
|
|
10
11
|
export class SunburstChart extends HierarchyChart {
|
|
11
12
|
static chartType = 'sunburst';
|
|
12
13
|
|
|
13
14
|
_render() {
|
|
14
|
-
const data = this.
|
|
15
|
-
const options = this.options;
|
|
15
|
+
const data = this._activeData();
|
|
16
|
+
const options = this.options || {};
|
|
16
17
|
const w = this.renderer?.width ?? 640;
|
|
17
18
|
const h = this.renderer?.height ?? 400;
|
|
18
|
-
|
|
19
|
+
let {
|
|
19
20
|
cx = w / 2, cy = h / 2 + 10,
|
|
20
21
|
radius = Math.min(w, h) / 2 - 60,
|
|
21
22
|
innerRadiusRatio = 0,
|
|
@@ -26,6 +27,12 @@ export class SunburstChart extends HierarchyChart {
|
|
|
26
27
|
this.renderer.text(title, cx, cy - radius - 20, { textAnchor: 'middle', fontSize: '18px', fill: '#333' });
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
const crumbH = this._drawBreadcrumbs(this.renderer, { x: 20, y: 10, width: w - 40 });
|
|
31
|
+
if (crumbH > 0) {
|
|
32
|
+
cy += crumbH / 2;
|
|
33
|
+
radius -= crumbH / 3;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
|
|
30
37
|
const innerHole = radius * innerRadiusRatio;
|
|
31
38
|
const ringWidth = (radius - innerHole) / maxDepth;
|
|
@@ -35,23 +42,36 @@ export class SunburstChart extends HierarchyChart {
|
|
|
35
42
|
const { node, cx: ecx, cy: ecy, rInner, rOuter, startAngle, endAngle, depth, topAncestor } = entry;
|
|
36
43
|
const color = this._colorForNode(topAncestor, depth);
|
|
37
44
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
45
|
+
const hasChildren = Boolean(node.children?.length);
|
|
38
46
|
|
|
39
47
|
const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
|
|
40
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
const arcEl = this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
49
|
+
cursor: (hasChildren && options.drilldown !== false) ? 'pointer' : 'default',
|
|
50
|
+
tooltip: `${node.label}: ${this._formatNodeValue(node)}${hasChildren ? ' (Click to drill down)' : ''}`,
|
|
51
|
+
}), { label: node.label });
|
|
52
|
+
|
|
53
|
+
if (hasChildren && options.drilldown !== false && typeof arcEl?.addEventListener === 'function') {
|
|
54
|
+
arcEl.addEventListener('click', (e) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
this.drillTo(node);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const midAngle = (startAngle + endAngle) / 2;
|
|
61
|
+
const hoverEls = [arcEl];
|
|
43
62
|
|
|
44
63
|
if (endAngle - startAngle > 0.35) {
|
|
45
|
-
const midAngle = (startAngle + endAngle) / 2;
|
|
46
64
|
const midR = (rInner + rOuter) / 2;
|
|
47
65
|
const labelX = ecx + midR * Math.cos(midAngle);
|
|
48
66
|
const labelY = ecy + midR * Math.sin(midAngle);
|
|
49
67
|
const upright = Math.cos(midAngle) >= 0;
|
|
50
68
|
const rotation = upright ? midAngle : midAngle + Math.PI;
|
|
51
|
-
this.renderer.text(node.label, labelX, labelY, {
|
|
69
|
+
const txtEl = this.renderer.text(node.label, labelX, labelY, {
|
|
52
70
|
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fill: textColor, rotation, 'pointer-events': 'none',
|
|
53
71
|
});
|
|
72
|
+
hoverEls.push(txtEl);
|
|
54
73
|
}
|
|
74
|
+
this._wireRadialHover(hoverEls, midAngle, { offset: options.hoverOffset ?? 8 });
|
|
55
75
|
});
|
|
56
76
|
}
|
|
57
77
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Chart3D } from '../Chart3D.js';
|
|
2
|
+
import { ColorPalette } from '../ColorPalette.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SurfaceChart3D — 3D `(x, z) -> value` surface mesh rendered through the real 4×4
|
|
6
|
+
* `OrbitCamera` (drag to rotate, wheel to zoom).
|
|
7
|
+
*
|
|
8
|
+
* Accepts either:
|
|
9
|
+
* - `Array<{x: string, z: string, value: number}>` (same flat grid contract as `barChart3DOrbit` and `heatmap`)
|
|
10
|
+
* - `{ xLabels: string[], zLabels: string[], values: number[][] }`
|
|
11
|
+
*/
|
|
12
|
+
export class SurfaceChart3D extends Chart3D {
|
|
13
|
+
static chartType = 'surface3D';
|
|
14
|
+
|
|
15
|
+
_render() {
|
|
16
|
+
const data = this.data;
|
|
17
|
+
const options = this.options || {};
|
|
18
|
+
const {
|
|
19
|
+
title,
|
|
20
|
+
colorScale = ['#313695', '#4575b4', '#74add1', '#fee090', '#f46d43', '#d73027'],
|
|
21
|
+
wireframe = true,
|
|
22
|
+
wireframeColor = 'rgba(255,255,255,0.45)',
|
|
23
|
+
initialTheta = -0.6,
|
|
24
|
+
initialPhi = 0.45,
|
|
25
|
+
initialDistance = 2.35,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
this.seedCamera({ theta: initialTheta, phi: initialPhi, distance: initialDistance });
|
|
29
|
+
const { cx, toScreen, box } = this._viewport();
|
|
30
|
+
const { y: by } = box;
|
|
31
|
+
|
|
32
|
+
if (title) {
|
|
33
|
+
this.renderer.text(title, cx, by - 20, { textAnchor: 'middle', fontSize: '18px', fill: '#333' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { xLabels, zLabels, grid } = this._normalizeGrid(data);
|
|
37
|
+
const nx = xLabels.length;
|
|
38
|
+
const nz = zLabels.length;
|
|
39
|
+
if (nx < 2 || nz < 2) return;
|
|
40
|
+
|
|
41
|
+
let minVal = Infinity;
|
|
42
|
+
let maxVal = -Infinity;
|
|
43
|
+
for (let ix = 0; ix < nx; ix++) {
|
|
44
|
+
for (let iz = 0; iz < nz; iz++) {
|
|
45
|
+
const v = grid[ix][iz];
|
|
46
|
+
if (v < minVal) minVal = v;
|
|
47
|
+
if (v > maxVal) maxVal = v;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const span = (maxVal - minVal) || 1;
|
|
51
|
+
|
|
52
|
+
const normX = (ix) => nx === 1 ? 0 : (ix / (nx - 1)) * 1.6 - 0.8;
|
|
53
|
+
const normZ = (iz) => nz === 1 ? 0 : (iz / (nz - 1)) * 1.6 - 0.8;
|
|
54
|
+
const normY = (v) => -0.55 + ((v - minVal) / span) * 1.15;
|
|
55
|
+
|
|
56
|
+
// Draw floor base frame
|
|
57
|
+
const floor = [
|
|
58
|
+
toScreen(-0.8, -0.55, -0.8),
|
|
59
|
+
toScreen(0.8, -0.55, -0.8),
|
|
60
|
+
toScreen(0.8, -0.55, 0.8),
|
|
61
|
+
toScreen(-0.8, -0.55, 0.8),
|
|
62
|
+
];
|
|
63
|
+
this.renderer.path(
|
|
64
|
+
`M${floor[0].x},${floor[0].y} L${floor[1].x},${floor[1].y} L${floor[2].x},${floor[2].y} L${floor[3].x},${floor[3].y} Z`,
|
|
65
|
+
'rgba(241, 245, 249, 0.55)',
|
|
66
|
+
{ color: '#cbd5e1', width: 1 }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Build surface quads for each (ix, iz) -> (ix+1, iz+1) cell
|
|
70
|
+
const quads = [];
|
|
71
|
+
for (let ix = 0; ix < nx - 1; ix++) {
|
|
72
|
+
for (let iz = 0; iz < nz - 1; iz++) {
|
|
73
|
+
const v00 = grid[ix][iz];
|
|
74
|
+
const v10 = grid[ix + 1][iz];
|
|
75
|
+
const v11 = grid[ix + 1][iz + 1];
|
|
76
|
+
const v01 = grid[ix][iz + 1];
|
|
77
|
+
const avgVal = (v00 + v10 + v11 + v01) / 4;
|
|
78
|
+
const t = Math.max(0, Math.min(1, (avgVal - minVal) / span));
|
|
79
|
+
|
|
80
|
+
const p00 = toScreen(normX(ix), normY(v00), normZ(iz));
|
|
81
|
+
const p10 = toScreen(normX(ix + 1), normY(v10), normZ(iz));
|
|
82
|
+
const p11 = toScreen(normX(ix + 1), normY(v11), normZ(iz + 1));
|
|
83
|
+
const p01 = toScreen(normX(ix), normY(v01), normZ(iz + 1));
|
|
84
|
+
|
|
85
|
+
// Directional slope shading based on finite differences along x and z
|
|
86
|
+
const dxSlope = (v10 + v11 - v00 - v01) / (2 * span);
|
|
87
|
+
const dzSlope = (v01 + v11 - v00 - v10) / (2 * span);
|
|
88
|
+
const lightShade = Math.max(-0.22, Math.min(0.22, dxSlope * 0.25 - dzSlope * 0.18));
|
|
89
|
+
const baseColor = this._sampleGradient(colorScale, t);
|
|
90
|
+
const shadedColor = ColorPalette.lighten(baseColor, lightShade);
|
|
91
|
+
|
|
92
|
+
const depth = (p00.depth + p10.depth + p11.depth + p01.depth) / 4;
|
|
93
|
+
quads.push({
|
|
94
|
+
pts: [p00, p10, p11, p01],
|
|
95
|
+
color: shadedColor,
|
|
96
|
+
depth,
|
|
97
|
+
label: `${xLabels[ix]}–${xLabels[ix + 1]}`,
|
|
98
|
+
series: `${zLabels[iz]}–${zLabels[iz + 1]}`,
|
|
99
|
+
avgVal,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Painter's sort: furthest (most negative depth) first
|
|
105
|
+
quads.sort((a, b) => a.depth - b.depth);
|
|
106
|
+
|
|
107
|
+
const stroke = wireframe ? { color: wireframeColor, width: 0.8 } : null;
|
|
108
|
+
for (const q of quads) {
|
|
109
|
+
const [p0, p1, p2, p3] = q.pts;
|
|
110
|
+
const d = `M${p0.x},${p0.y} L${p1.x},${p1.y} L${p2.x},${p2.y} L${p3.x},${p3.y} Z`;
|
|
111
|
+
const el = this._datum(this.renderer.path(d, q.color, stroke, {
|
|
112
|
+
tooltip: `${q.label} / ${q.series}: ${q.avgVal.toFixed(2)}`,
|
|
113
|
+
}), { label: q.label, series: q.series });
|
|
114
|
+
this._wireHoverTransform([el], { dx: 0, dy: -4 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Draw X and Z floor tick labels
|
|
118
|
+
const xStep = Math.max(1, Math.ceil(nx / 6));
|
|
119
|
+
for (let ix = 0; ix < nx; ix += xStep) {
|
|
120
|
+
const lp = toScreen(normX(ix), -0.58, 0.92);
|
|
121
|
+
this.renderer.text(String(xLabels[ix]), lp.x, lp.y, { textAnchor: 'middle', fontSize: '10px', fill: '#475569' });
|
|
122
|
+
}
|
|
123
|
+
const zStep = Math.max(1, Math.ceil(nz / 6));
|
|
124
|
+
for (let iz = 0; iz < nz; iz += zStep) {
|
|
125
|
+
const lp = toScreen(0.92, -0.58, normZ(iz));
|
|
126
|
+
this.renderer.text(String(zLabels[iz]), lp.x, lp.y, { textAnchor: 'middle', fontSize: '10px', fill: '#475569' });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_normalizeGrid(data) {
|
|
131
|
+
if (data && !Array.isArray(data) && Array.isArray(data.values)) {
|
|
132
|
+
const xLabels = data.xLabels || data.values.map((_, i) => String(i));
|
|
133
|
+
const zLabels = data.zLabels || (data.values[0] || []).map((_, j) => String(j));
|
|
134
|
+
return { xLabels, zLabels, grid: data.values };
|
|
135
|
+
}
|
|
136
|
+
const rows = Array.isArray(data) ? data : [];
|
|
137
|
+
const { xLabels, zLabels, xIndex, zIndex } = this.bucketGrid(rows);
|
|
138
|
+
const grid = Array.from({ length: xLabels.length }, () => new Array(zLabels.length).fill(0));
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
const ix = xIndex.get(r.x);
|
|
141
|
+
const iz = zIndex.get(r.z);
|
|
142
|
+
if (ix != null && iz != null) {
|
|
143
|
+
grid[ix][iz] = Number(r.value) || 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { xLabels, zLabels, grid };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_sampleGradient(stops, t) {
|
|
150
|
+
if (!Array.isArray(stops) || stops.length === 0) return '#4f8ef7';
|
|
151
|
+
if (stops.length === 1) return stops[0];
|
|
152
|
+
const scaled = Math.max(0, Math.min(1, t)) * (stops.length - 1);
|
|
153
|
+
const idx = Math.min(stops.length - 2, Math.floor(scaled));
|
|
154
|
+
const localT = scaled - idx;
|
|
155
|
+
return ColorPalette.interpolate(stops[idx], stops[idx + 1], localT);
|
|
156
|
+
}
|
|
157
|
+
}
|