@maxhealth.tech/prefab 0.2.40 → 0.3.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/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/dist/app.d.ts +24 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +46 -9
- package/dist/app.js.map +1 -1
- package/dist/components/charts/index.d.ts +56 -4
- package/dist/components/charts/index.d.ts.map +1 -1
- package/dist/components/charts/index.js +33 -7
- package/dist/components/charts/index.js.map +1 -1
- package/dist/core/theme-css.d.ts +32 -0
- package/dist/core/theme-css.d.ts.map +1 -0
- package/dist/core/theme-css.js +55 -0
- package/dist/core/theme-css.js.map +1 -0
- package/dist/core/validate.d.ts.map +1 -1
- package/dist/core/validate.js +24 -1
- package/dist/core/validate.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/display.d.ts +6 -2
- package/dist/mcp/display.d.ts.map +1 -1
- package/dist/mcp/display.js +8 -2
- package/dist/mcp/display.js.map +1 -1
- package/dist/renderer/components/chart-helpers.d.ts +63 -0
- package/dist/renderer/components/chart-helpers.d.ts.map +1 -0
- package/dist/renderer/components/chart-helpers.js +202 -0
- package/dist/renderer/components/chart-helpers.js.map +1 -0
- package/dist/renderer/components/chart-tooltip.d.ts +1 -1
- package/dist/renderer/components/chart-tooltip.d.ts.map +1 -1
- package/dist/renderer/components/charts.d.ts +4 -20
- package/dist/renderer/components/charts.d.ts.map +1 -1
- package/dist/renderer/components/charts.js +246 -195
- package/dist/renderer/components/charts.js.map +1 -1
- package/dist/renderer/index.d.ts +6 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +65 -24
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/theme.d.ts +15 -0
- package/dist/renderer/theme.d.ts.map +1 -1
- package/dist/renderer/theme.js +26 -18
- package/dist/renderer/theme.js.map +1 -1
- package/dist/renderer.auto.min.js +14 -12
- package/dist/renderer.min.js +14 -12
- package/package.json +1 -1
|
@@ -1,155 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chart component renderers — BarChart, LineChart, AreaChart, PieChart,
|
|
2
|
+
* Chart component renderers — BarChart, LineChart, AreaChart, PieChart,
|
|
3
|
+
* ScatterChart, RadialChart, Histogram.
|
|
3
4
|
*
|
|
4
|
-
* Charts render as SVG using simple built-in drawing.
|
|
5
|
-
*
|
|
5
|
+
* Charts render as SVG using simple built-in drawing. Shared axis/SVG/format
|
|
6
|
+
* helpers live in [chart-helpers.ts]; tooltip hit-zones in [chart-tooltip.ts].
|
|
6
7
|
*/
|
|
7
8
|
import { registerComponent, resolveValue, el } from '../engine.js';
|
|
8
9
|
import { createTooltip, addBarTooltipZones, addLineTooltipZones, showTooltipAt } from './chart-tooltip.js';
|
|
10
|
+
import { COLORS, AXIS_COLOR, GRID_COLOR, AXIS_FONT, chartLayout, createSvg, svgEl, svgText, polar, arcPath, drawYAxis, drawYAxisRight, drawXAxisLabels, drawBaseline, applyPipeFormat, resolveValueFormat, makeTooltipFormatter, addLegend, } from './chart-helpers.js';
|
|
9
11
|
export function registerChartComponents() {
|
|
10
12
|
registerComponent('BarChart', renderBarChart);
|
|
11
13
|
registerComponent('LineChart', renderLineChart);
|
|
12
14
|
registerComponent('AreaChart', renderLineChart); // Same renderer, different fill
|
|
13
15
|
registerComponent('PieChart', renderPieChart);
|
|
14
|
-
registerComponent('RadarChart',
|
|
15
|
-
registerComponent('ScatterChart',
|
|
16
|
-
registerComponent('RadialChart',
|
|
16
|
+
registerComponent('RadarChart', renderRadarChart);
|
|
17
|
+
registerComponent('ScatterChart', renderScatterChart);
|
|
18
|
+
registerComponent('RadialChart', renderRadialChart);
|
|
17
19
|
registerComponent('Histogram', renderHistogram);
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
const AXIS_COLOR = 'var(--muted-foreground, #6b7280)';
|
|
21
|
-
const GRID_COLOR = 'var(--border, #e5e7eb)';
|
|
22
|
-
const AXIS_FONT = '10';
|
|
23
|
-
/** Compute chart layout accounting for optional Y-axis label space. */
|
|
24
|
-
function chartLayout(svgWidth, svgHeight, hasYAxis, hasYAxisRight = false) {
|
|
25
|
-
const plotLeft = hasYAxis ? 44 : 0;
|
|
26
|
-
const plotRight = svgWidth - (hasYAxisRight ? 44 : 0);
|
|
27
|
-
const plotTop = 10;
|
|
28
|
-
const plotBottom = svgHeight - 24;
|
|
29
|
-
return {
|
|
30
|
-
plotLeft,
|
|
31
|
-
plotRight,
|
|
32
|
-
plotTop,
|
|
33
|
-
plotBottom,
|
|
34
|
-
plotWidth: plotRight - plotLeft,
|
|
35
|
-
plotHeight: plotBottom - plotTop,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/** Nice round tick values for a 0..max range, returning ~tickCount values. */
|
|
39
|
-
function niceYTicks(max, tickCount = 5) {
|
|
40
|
-
if (max <= 0)
|
|
41
|
-
return [0];
|
|
42
|
-
const rawStep = max / tickCount;
|
|
43
|
-
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
44
|
-
const residual = rawStep / magnitude;
|
|
45
|
-
const niceStep = residual <= 1.5 ? magnitude
|
|
46
|
-
: residual <= 3 ? 2 * magnitude
|
|
47
|
-
: residual <= 7 ? 5 * magnitude
|
|
48
|
-
: 10 * magnitude;
|
|
49
|
-
const ticks = [];
|
|
50
|
-
for (let v = 0; v <= max + niceStep * 0.01; v += niceStep) {
|
|
51
|
-
ticks.push(Math.round(v * 1000) / 1000);
|
|
52
|
-
}
|
|
53
|
-
return ticks;
|
|
54
|
-
}
|
|
55
|
-
/** Draw Y-axis labels + optional horizontal grid lines into SVG. */
|
|
56
|
-
function drawYAxis(svg, layout, max, showGrid, format) {
|
|
57
|
-
const ticks = niceYTicks(max);
|
|
58
|
-
for (const tick of ticks) {
|
|
59
|
-
const y = layout.plotBottom - (max > 0 ? (tick / max) * layout.plotHeight : 0);
|
|
60
|
-
// Y label
|
|
61
|
-
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
62
|
-
label.setAttribute('x', String(layout.plotLeft - 6));
|
|
63
|
-
label.setAttribute('y', String(y + 3));
|
|
64
|
-
label.setAttribute('text-anchor', 'end');
|
|
65
|
-
label.setAttribute('font-size', AXIS_FONT);
|
|
66
|
-
label.setAttribute('fill', AXIS_COLOR);
|
|
67
|
-
label.textContent = formatYValue(tick, format);
|
|
68
|
-
svg.appendChild(label);
|
|
69
|
-
// Grid line
|
|
70
|
-
if (showGrid && tick > 0) {
|
|
71
|
-
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
72
|
-
line.setAttribute('x1', String(layout.plotLeft));
|
|
73
|
-
line.setAttribute('y1', String(y));
|
|
74
|
-
line.setAttribute('x2', String(layout.plotRight));
|
|
75
|
-
line.setAttribute('y2', String(y));
|
|
76
|
-
line.setAttribute('stroke', GRID_COLOR);
|
|
77
|
-
line.setAttribute('stroke-width', '1');
|
|
78
|
-
line.setAttribute('stroke-dasharray', '4 3');
|
|
79
|
-
svg.appendChild(line);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/** Draw secondary Y-axis labels on the right side of the plot. */
|
|
84
|
-
function drawYAxisRight(svg, layout, max, format) {
|
|
85
|
-
const ticks = niceYTicks(max);
|
|
86
|
-
for (const tick of ticks) {
|
|
87
|
-
const y = layout.plotBottom - (max > 0 ? (tick / max) * layout.plotHeight : 0);
|
|
88
|
-
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
89
|
-
label.setAttribute('x', String(layout.plotRight + 6));
|
|
90
|
-
label.setAttribute('y', String(y + 3));
|
|
91
|
-
label.setAttribute('text-anchor', 'start');
|
|
92
|
-
label.setAttribute('font-size', AXIS_FONT);
|
|
93
|
-
label.setAttribute('fill', AXIS_COLOR);
|
|
94
|
-
label.textContent = formatYValue(tick, format);
|
|
95
|
-
svg.appendChild(label);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/** Draw X-axis labels under the plot area. */
|
|
99
|
-
function drawXAxisLabels(svg, data, xAxisKey, getX, yBase, format) {
|
|
100
|
-
for (let i = 0; i < data.length; i++) {
|
|
101
|
-
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
102
|
-
label.setAttribute('x', String(getX(i)));
|
|
103
|
-
label.setAttribute('y', String(yBase + 14));
|
|
104
|
-
label.setAttribute('text-anchor', 'middle');
|
|
105
|
-
label.setAttribute('font-size', AXIS_FONT);
|
|
106
|
-
label.setAttribute('fill', AXIS_COLOR);
|
|
107
|
-
const val = data[i][xAxisKey];
|
|
108
|
-
label.textContent = val == null ? '' : (format ? format(val) : String(val));
|
|
109
|
-
svg.appendChild(label);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
/** Draw a baseline (X-axis line) at the bottom of the plot. */
|
|
113
|
-
function drawBaseline(svg, layout) {
|
|
114
|
-
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
115
|
-
line.setAttribute('x1', String(layout.plotLeft));
|
|
116
|
-
line.setAttribute('y1', String(layout.plotBottom));
|
|
117
|
-
line.setAttribute('x2', String(layout.plotRight));
|
|
118
|
-
line.setAttribute('y2', String(layout.plotBottom));
|
|
119
|
-
line.setAttribute('stroke', AXIS_COLOR);
|
|
120
|
-
line.setAttribute('stroke-width', '1');
|
|
121
|
-
svg.appendChild(line);
|
|
122
|
-
}
|
|
123
|
-
/** Apply a pipe expression to a value using the Rx engine. */
|
|
124
|
-
function applyPipeFormat(value, pipe, ctx) {
|
|
125
|
-
if (value == null)
|
|
126
|
-
return '';
|
|
127
|
-
const result = resolveValue(`{{ __v | ${pipe} }}`, { ...ctx, scope: { ...ctx.scope, __v: value } });
|
|
128
|
-
return result == null ? String(value) : String(result);
|
|
129
|
-
}
|
|
130
|
-
export function formatYValue(value, format) {
|
|
131
|
-
if (format === 'currency')
|
|
132
|
-
return `$${value.toLocaleString()}`;
|
|
133
|
-
if (format === 'percent')
|
|
134
|
-
return `${value}%`;
|
|
135
|
-
if (value >= 1_000_000)
|
|
136
|
-
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
137
|
-
if (value >= 1_000)
|
|
138
|
-
return `${(value / 1_000).toFixed(1)}K`;
|
|
139
|
-
return String(value);
|
|
140
|
-
}
|
|
141
|
-
/** Create a format callback for tooltip entries that handles per-axis formats + null. */
|
|
142
|
-
function makeTooltipFormatter(ctx, yAxisFormat, yAxisRightFormat) {
|
|
143
|
-
return (raw, s) => {
|
|
144
|
-
if (raw === null || raw === undefined)
|
|
145
|
-
return '\u2014';
|
|
146
|
-
// Per-series tooltipFormat overrides axis format
|
|
147
|
-
if (s.tooltipFormat)
|
|
148
|
-
return applyPipeFormat(raw, s.tooltipFormat, ctx);
|
|
149
|
-
const fmt = s.yAxisId === 'right' ? yAxisRightFormat : yAxisFormat;
|
|
150
|
-
return formatYValue(Number(raw), fmt);
|
|
151
|
-
};
|
|
152
|
-
}
|
|
21
|
+
// ── BarChart ─────────────────────────────────────────────────────────────────
|
|
153
22
|
function renderBarChart(node, ctx) {
|
|
154
23
|
const wrapper = el('div', 'pf-chart pf-bar-chart');
|
|
155
24
|
const data = resolveValue(node.data, ctx) ?? [];
|
|
@@ -181,7 +50,7 @@ function renderBarChart(node, ctx) {
|
|
|
181
50
|
const svg = createSvg(w, height, 'Bar');
|
|
182
51
|
// Axes + grid (behind bars)
|
|
183
52
|
if (showYAxis)
|
|
184
|
-
drawYAxis(svg, layout, leftMax, showGrid, node
|
|
53
|
+
drawYAxis(svg, layout, leftMax, showGrid, resolveValueFormat(node));
|
|
185
54
|
if (hasRight)
|
|
186
55
|
drawYAxisRight(svg, layout, rightMax, node.yAxisRightFormat);
|
|
187
56
|
drawBaseline(svg, layout);
|
|
@@ -219,7 +88,7 @@ function renderBarChart(node, ctx) {
|
|
|
219
88
|
// Tooltip hit-zones (on top of bars)
|
|
220
89
|
if (showTooltipProp) {
|
|
221
90
|
const ttCtx = createTooltip(wrapper, svg);
|
|
222
|
-
const fmt = makeTooltipFormatter(ctx, node
|
|
91
|
+
const fmt = makeTooltipFormatter(ctx, resolveValueFormat(node), node.yAxisRightFormat);
|
|
223
92
|
const ttLabelFmt = tooltipXFormat ? (v) => applyPipeFormat(v, tooltipXFormat, ctx) : undefined;
|
|
224
93
|
addBarTooltipZones(ttCtx, svg, data, series, layout, tooltipXKey ?? xAxisKey, fmt, ttLabelFmt);
|
|
225
94
|
}
|
|
@@ -227,6 +96,7 @@ function renderBarChart(node, ctx) {
|
|
|
227
96
|
wrapper.appendChild(svg);
|
|
228
97
|
return wrapper;
|
|
229
98
|
}
|
|
99
|
+
// ── LineChart / AreaChart ────────────────────────────────────────────────────
|
|
230
100
|
function renderLineChart(node, ctx) {
|
|
231
101
|
const wrapper = el('div', `pf-chart pf-${node.type.toLowerCase()}-chart`);
|
|
232
102
|
const data = resolveValue(node.data, ctx) ?? [];
|
|
@@ -261,7 +131,7 @@ function renderLineChart(node, ctx) {
|
|
|
261
131
|
const isArea = node.type === 'AreaChart';
|
|
262
132
|
// Draw grid + axes (behind data)
|
|
263
133
|
if (showYAxis)
|
|
264
|
-
drawYAxis(svg, layout, leftMax, showGrid, node
|
|
134
|
+
drawYAxis(svg, layout, leftMax, showGrid, resolveValueFormat(node));
|
|
265
135
|
if (hasRight)
|
|
266
136
|
drawYAxisRight(svg, layout, rightMax, node.yAxisRightFormat);
|
|
267
137
|
drawBaseline(svg, layout);
|
|
@@ -371,7 +241,7 @@ function renderLineChart(node, ctx) {
|
|
|
371
241
|
// Tooltip hit-zones (on top of lines)
|
|
372
242
|
if (showTooltipProp) {
|
|
373
243
|
const ttCtx = createTooltip(wrapper, svg);
|
|
374
|
-
const fmt = makeTooltipFormatter(ctx, node
|
|
244
|
+
const fmt = makeTooltipFormatter(ctx, resolveValueFormat(node), node.yAxisRightFormat);
|
|
375
245
|
const ttLabelFmt = tooltipXFormat ? (v) => applyPipeFormat(v, tooltipXFormat, ctx) : undefined;
|
|
376
246
|
addLineTooltipZones(ttCtx, svg, data, allSeries, layout, tooltipXKey ?? xAxisKey, fmt, crosshair, dotGroups, ttLabelFmt);
|
|
377
247
|
}
|
|
@@ -379,14 +249,25 @@ function renderLineChart(node, ctx) {
|
|
|
379
249
|
wrapper.appendChild(svg);
|
|
380
250
|
return wrapper;
|
|
381
251
|
}
|
|
252
|
+
// ── PieChart ─────────────────────────────────────────────────────────────────
|
|
382
253
|
function renderPieChart(node, ctx) {
|
|
383
254
|
const wrapper = el('div', 'pf-chart pf-pie-chart');
|
|
384
255
|
const data = resolveValue(node.data, ctx) ?? [];
|
|
385
|
-
const
|
|
256
|
+
const seriesIn = node.series ?? [];
|
|
386
257
|
const height = node.height ?? 300;
|
|
387
258
|
const size = Math.min(height, 300);
|
|
388
259
|
const showTooltipProp = node.showTooltip !== false;
|
|
389
|
-
|
|
260
|
+
// Value field: upstream `dataKey`, falling back to series[0] (legacy series-based input).
|
|
261
|
+
const key = node.dataKey ?? seriesIn[0]?.dataKey;
|
|
262
|
+
// Slice-label field: upstream `nameKey`, falling back to tooltipXKey / xAxis (legacy).
|
|
263
|
+
const nameKey = node.nameKey
|
|
264
|
+
?? node.tooltipXKey
|
|
265
|
+
?? node.xAxis;
|
|
266
|
+
// Synthesize a single series for the legend/tooltip when only dataKey/nameKey is given.
|
|
267
|
+
const series = seriesIn.length > 0
|
|
268
|
+
? seriesIn
|
|
269
|
+
: (key ? [{ dataKey: key, label: key }] : []);
|
|
270
|
+
if (data.length === 0 || !key) {
|
|
390
271
|
wrapper.textContent = 'No chart data';
|
|
391
272
|
return wrapper;
|
|
392
273
|
}
|
|
@@ -394,11 +275,9 @@ function renderPieChart(node, ctx) {
|
|
|
394
275
|
const cx = size / 2;
|
|
395
276
|
const cy = size / 2;
|
|
396
277
|
const r = size / 2 - 10;
|
|
397
|
-
// Use first series key, each data point is a slice
|
|
398
|
-
const key = series[0].dataKey;
|
|
399
|
-
const xAxisKey = node.xAxis;
|
|
400
|
-
const tooltipXKey = node.tooltipXKey;
|
|
401
278
|
const tooltipXFormat = node.tooltipXFormat;
|
|
279
|
+
const valueFmt = resolveValueFormat(node);
|
|
280
|
+
const fmtValue = (v) => valueFmt ? applyPipeFormat(v, valueFmt, ctx) : String(v);
|
|
402
281
|
const values = data.map(d => Number(d[key] ?? 0));
|
|
403
282
|
const total = values.reduce((a, b) => a + b, 0);
|
|
404
283
|
if (total === 0) {
|
|
@@ -422,8 +301,7 @@ function renderPieChart(node, ctx) {
|
|
|
422
301
|
path.setAttribute('fill', color);
|
|
423
302
|
path.style.cursor = 'default';
|
|
424
303
|
if (ttCtx) {
|
|
425
|
-
const
|
|
426
|
-
const rawSlice = sliceKey ? data[i][sliceKey] : undefined;
|
|
304
|
+
const rawSlice = nameKey ? data[i][nameKey] : undefined;
|
|
427
305
|
const sliceLabel = rawSlice != null
|
|
428
306
|
? (tooltipXFormat ? applyPipeFormat(rawSlice, tooltipXFormat, ctx) : String(rawSlice))
|
|
429
307
|
: `Slice ${i + 1}`;
|
|
@@ -432,7 +310,7 @@ function renderPieChart(node, ctx) {
|
|
|
432
310
|
const tipX = cx + (r * 0.6) * Math.cos(midAngle);
|
|
433
311
|
path.addEventListener('mouseenter', () => {
|
|
434
312
|
showTooltipAt(ttCtx, tipX, cy, sliceLabel, [
|
|
435
|
-
{ label: series[0].label ?? key, value: `${values[i]} (${pct})`, color },
|
|
313
|
+
{ label: series[0].label ?? key, value: `${fmtValue(values[i])} (${pct})`, color },
|
|
436
314
|
]);
|
|
437
315
|
});
|
|
438
316
|
path.addEventListener('mouseleave', () => {
|
|
@@ -440,7 +318,7 @@ function renderPieChart(node, ctx) {
|
|
|
440
318
|
});
|
|
441
319
|
path.addEventListener('touchstart', () => {
|
|
442
320
|
showTooltipAt(ttCtx, tipX, cy, sliceLabel, [
|
|
443
|
-
{ label: series[0].label ?? key, value: `${values[i]} (${pct})`, color },
|
|
321
|
+
{ label: series[0].label ?? key, value: `${fmtValue(values[i])} (${pct})`, color },
|
|
444
322
|
]);
|
|
445
323
|
}, { passive: true });
|
|
446
324
|
path.addEventListener('touchend', () => {
|
|
@@ -454,51 +332,224 @@ function renderPieChart(node, ctx) {
|
|
|
454
332
|
wrapper.appendChild(svg);
|
|
455
333
|
return wrapper;
|
|
456
334
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
335
|
+
// ── ScatterChart ─────────────────────────────────────────────────────────────
|
|
336
|
+
function renderScatterChart(node, ctx) {
|
|
337
|
+
const wrapper = el('div', 'pf-chart pf-scatter-chart');
|
|
338
|
+
const data = resolveValue(node.data, ctx) ?? [];
|
|
339
|
+
const xKey = node.xAxis;
|
|
340
|
+
const yKey = node.yAxis;
|
|
341
|
+
const zKey = node.zAxis;
|
|
342
|
+
const series = node.series ?? [];
|
|
343
|
+
const height = node.height ?? 300;
|
|
344
|
+
if (data.length === 0 || !xKey || !yKey) {
|
|
345
|
+
wrapper.textContent = 'No chart data';
|
|
346
|
+
return wrapper;
|
|
347
|
+
}
|
|
348
|
+
const showGrid = node.showGrid !== false;
|
|
349
|
+
const showTooltipProp = node.showTooltip !== false;
|
|
350
|
+
const color = series[0]?.color ?? COLORS[0];
|
|
351
|
+
const valueFmt = resolveValueFormat(node);
|
|
352
|
+
const fmt = (v) => valueFmt ? applyPipeFormat(v, valueFmt, ctx) : String(Math.round(v * 100) / 100);
|
|
353
|
+
const xs = data.map(d => Number(d[xKey] ?? 0));
|
|
354
|
+
const ys = data.map(d => Number(d[yKey] ?? 0));
|
|
355
|
+
const xMin = Math.min(...xs), xMax = Math.max(...xs);
|
|
356
|
+
const yMin = Math.min(...ys), yMax = Math.max(...ys);
|
|
357
|
+
const xRange = (xMax - xMin) || 1;
|
|
358
|
+
const yRange = (yMax - yMin) || 1;
|
|
359
|
+
const zs = zKey ? data.map(d => Number(d[zKey] ?? 0)) : [];
|
|
360
|
+
const zMax = zKey ? Math.max(...zs, 1) : 1;
|
|
361
|
+
const W = 400;
|
|
362
|
+
const layout = chartLayout(W, height, true);
|
|
363
|
+
const svg = createSvg(W, height, 'Scatter');
|
|
364
|
+
const sx = (v) => layout.plotLeft + ((v - xMin) / xRange) * layout.plotWidth;
|
|
365
|
+
const sy = (v) => layout.plotBottom - ((v - yMin) / yRange) * layout.plotHeight;
|
|
366
|
+
// Y axis ticks across the actual [min, max] range (+ optional grid).
|
|
367
|
+
const Y_TICKS = 4;
|
|
368
|
+
for (let t = 0; t <= Y_TICKS; t++) {
|
|
369
|
+
const val = yMin + (yRange * t) / Y_TICKS;
|
|
370
|
+
const y = sy(val);
|
|
371
|
+
svg.appendChild(svgText({ x: layout.plotLeft - 6, y: y + 3, 'text-anchor': 'end', 'font-size': AXIS_FONT, fill: AXIS_COLOR }, fmt(val)));
|
|
372
|
+
if (showGrid && t > 0) {
|
|
373
|
+
svg.appendChild(svgEl('line', {
|
|
374
|
+
x1: layout.plotLeft, y1: y, x2: layout.plotRight, y2: y,
|
|
375
|
+
stroke: GRID_COLOR, 'stroke-width': 1, 'stroke-dasharray': '4 3',
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
drawBaseline(svg, layout);
|
|
380
|
+
// X axis: min / mid / max labels.
|
|
381
|
+
for (const xv of [xMin, (xMin + xMax) / 2, xMax]) {
|
|
382
|
+
svg.appendChild(svgText({ x: sx(xv), y: layout.plotBottom + 14, 'text-anchor': 'middle', 'font-size': AXIS_FONT, fill: AXIS_COLOR }, String(Math.round(xv * 100) / 100)));
|
|
383
|
+
}
|
|
384
|
+
const ttCtx = showTooltipProp ? createTooltip(wrapper, svg) : undefined;
|
|
385
|
+
for (let i = 0; i < data.length; i++) {
|
|
386
|
+
const px = sx(xs[i]);
|
|
387
|
+
const py = sy(ys[i]);
|
|
388
|
+
// Bubble radius from z (sqrt → area-proportional); fixed dot otherwise.
|
|
389
|
+
const rad = zKey ? 4 + Math.sqrt(zs[i] / zMax) * 14 : 4;
|
|
390
|
+
const dot = svgEl('circle', { cx: px, cy: py, r: rad, fill: color, 'fill-opacity': zKey ? 0.6 : 0.85 });
|
|
391
|
+
if (ttCtx) {
|
|
392
|
+
const entries = [
|
|
393
|
+
{ label: xKey, value: fmt(xs[i]), color },
|
|
394
|
+
{ label: yKey, value: fmt(ys[i]), color },
|
|
395
|
+
...(zKey ? [{ label: zKey, value: fmt(zs[i]), color }] : []),
|
|
396
|
+
];
|
|
397
|
+
const title = series[0]?.label ?? 'Point';
|
|
398
|
+
dot.addEventListener('mouseenter', () => showTooltipAt(ttCtx, px, py, title, entries));
|
|
399
|
+
dot.addEventListener('mouseleave', () => ttCtx.tooltip.classList.remove('pf-visible'));
|
|
400
|
+
dot.addEventListener('touchstart', () => showTooltipAt(ttCtx, px, py, title, entries), { passive: true });
|
|
401
|
+
dot.addEventListener('touchend', () => ttCtx.tooltip.classList.remove('pf-visible'), { passive: true });
|
|
402
|
+
}
|
|
403
|
+
svg.appendChild(dot);
|
|
404
|
+
}
|
|
405
|
+
addLegend(wrapper, series, node.showLegend);
|
|
406
|
+
wrapper.appendChild(svg);
|
|
407
|
+
return wrapper;
|
|
463
408
|
}
|
|
464
|
-
// ──
|
|
465
|
-
function
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
409
|
+
// ── RadialChart ──────────────────────────────────────────────────────────────
|
|
410
|
+
function renderRadialChart(node, ctx) {
|
|
411
|
+
const wrapper = el('div', 'pf-chart pf-radial-chart');
|
|
412
|
+
const data = resolveValue(node.data, ctx) ?? [];
|
|
413
|
+
const seriesIn = node.series ?? [];
|
|
414
|
+
const dataKey = node.dataKey ?? seriesIn[0]?.dataKey;
|
|
415
|
+
const nameKey = node.nameKey
|
|
416
|
+
?? node.tooltipXKey
|
|
417
|
+
?? node.xAxis;
|
|
418
|
+
const height = node.height ?? 300;
|
|
419
|
+
if (data.length === 0 || !dataKey) {
|
|
420
|
+
wrapper.textContent = 'No chart data';
|
|
421
|
+
return wrapper;
|
|
473
422
|
}
|
|
474
|
-
|
|
475
|
-
|
|
423
|
+
const size = Math.min(height, 300);
|
|
424
|
+
const cx = size / 2;
|
|
425
|
+
const cy = size / 2;
|
|
426
|
+
const outer = size / 2 - 10;
|
|
427
|
+
const inner = Math.max(0, Math.min(node.innerRadius ?? 30, outer - 10));
|
|
428
|
+
const startAngle = node.startAngle ?? 180;
|
|
429
|
+
const endAngle = node.endAngle ?? 0;
|
|
430
|
+
const showTooltipProp = node.showTooltip !== false;
|
|
431
|
+
const values = data.map(d => Number(d[dataKey] ?? 0));
|
|
432
|
+
const max = Math.max(...values, 1);
|
|
433
|
+
const svg = createSvg(size, size, 'Radial');
|
|
434
|
+
const band = (outer - inner) / data.length;
|
|
435
|
+
const thickness = Math.max(2, band * 0.7);
|
|
436
|
+
const valueFmt = resolveValueFormat(node);
|
|
437
|
+
const fmt = (v) => valueFmt ? applyPipeFormat(v, valueFmt, ctx) : String(v);
|
|
438
|
+
const ttCtx = showTooltipProp ? createTooltip(wrapper, svg) : undefined;
|
|
439
|
+
const legendSeries = [];
|
|
440
|
+
for (let i = 0; i < data.length; i++) {
|
|
441
|
+
// First row is the outermost ring.
|
|
442
|
+
const rMid = outer - band * i - band / 2;
|
|
443
|
+
const color = seriesIn[i]?.color ?? COLORS[i % COLORS.length];
|
|
444
|
+
const frac = values[i] / max;
|
|
445
|
+
const valueEnd = startAngle + frac * (endAngle - startAngle);
|
|
446
|
+
const rawName = nameKey ? data[i][nameKey] : null;
|
|
447
|
+
const name = rawName != null ? String(rawName) : (seriesIn[i]?.label ?? dataKey);
|
|
448
|
+
legendSeries.push({ dataKey: name || dataKey, label: name || dataKey, color });
|
|
449
|
+
// Muted full-range track.
|
|
450
|
+
svg.appendChild(svgEl('path', {
|
|
451
|
+
d: arcPath(cx, cy, rMid, startAngle, endAngle),
|
|
452
|
+
fill: 'none', stroke: GRID_COLOR, 'stroke-width': thickness, 'stroke-linecap': 'round',
|
|
453
|
+
}));
|
|
454
|
+
// Coloured value arc.
|
|
455
|
+
const arc = svgEl('path', {
|
|
456
|
+
d: arcPath(cx, cy, rMid, startAngle, valueEnd),
|
|
457
|
+
fill: 'none', stroke: color, 'stroke-width': thickness, 'stroke-linecap': 'round',
|
|
458
|
+
});
|
|
459
|
+
if (ttCtx) {
|
|
460
|
+
const mid = polar(cx, cy, rMid, (startAngle + valueEnd) / 2);
|
|
461
|
+
const entries = [{ label: dataKey, value: fmt(values[i]), color }];
|
|
462
|
+
const title = name || dataKey;
|
|
463
|
+
arc.addEventListener('mouseenter', () => showTooltipAt(ttCtx, mid.x, mid.y, title, entries));
|
|
464
|
+
arc.addEventListener('mouseleave', () => ttCtx.tooltip.classList.remove('pf-visible'));
|
|
465
|
+
arc.addEventListener('touchstart', () => showTooltipAt(ttCtx, mid.x, mid.y, title, entries), { passive: true });
|
|
466
|
+
arc.addEventListener('touchend', () => ttCtx.tooltip.classList.remove('pf-visible'), { passive: true });
|
|
467
|
+
}
|
|
468
|
+
svg.appendChild(arc);
|
|
469
|
+
}
|
|
470
|
+
addLegend(wrapper, legendSeries, node.showLegend);
|
|
471
|
+
wrapper.appendChild(svg);
|
|
472
|
+
return wrapper;
|
|
476
473
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
474
|
+
// ── RadarChart ───────────────────────────────────────────────────────────────
|
|
475
|
+
function renderRadarChart(node, ctx) {
|
|
476
|
+
const wrapper = el('div', 'pf-chart pf-radar-chart');
|
|
477
|
+
const data = resolveValue(node.data, ctx) ?? [];
|
|
478
|
+
const series = node.series ?? [];
|
|
479
|
+
const axisKey = node.axisKey ?? node.xAxis;
|
|
480
|
+
const height = node.height ?? 300;
|
|
481
|
+
if (data.length === 0 || series.length === 0) {
|
|
482
|
+
wrapper.textContent = 'No chart data';
|
|
483
|
+
return wrapper;
|
|
484
|
+
}
|
|
485
|
+
const filled = node.filled !== false;
|
|
486
|
+
const showDots = node.showDots === true;
|
|
487
|
+
const showGrid = node.showGrid !== false;
|
|
488
|
+
const showTooltipProp = node.showTooltip !== false;
|
|
489
|
+
const size = Math.min(height, 320);
|
|
490
|
+
const cx = size / 2;
|
|
491
|
+
const cy = size / 2;
|
|
492
|
+
const R = size / 2 - 24; // leave room for spoke labels
|
|
493
|
+
const N = data.length;
|
|
494
|
+
const max = Math.max(...data.flatMap(d => series.map(s => Number(d[s.dataKey] ?? 0))), 1);
|
|
495
|
+
// Spokes start at the top and go clockwise.
|
|
496
|
+
const angleAt = (i) => 90 - (360 * i) / N;
|
|
497
|
+
const ptStr = (pts) => pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
|
498
|
+
const svg = createSvg(size, size, 'Radar');
|
|
499
|
+
// Polar grid: concentric rings + spokes.
|
|
500
|
+
if (showGrid) {
|
|
501
|
+
const rings = 4;
|
|
502
|
+
for (let r = 1; r <= rings; r++) {
|
|
503
|
+
const rr = (R * r) / rings;
|
|
504
|
+
const ring = Array.from({ length: N }, (_unused, i) => polar(cx, cy, rr, angleAt(i)));
|
|
505
|
+
svg.appendChild(svgEl('polygon', { points: ptStr(ring), fill: 'none', stroke: GRID_COLOR, 'stroke-width': 1 }));
|
|
506
|
+
}
|
|
507
|
+
for (let i = 0; i < N; i++) {
|
|
508
|
+
const p = polar(cx, cy, R, angleAt(i));
|
|
509
|
+
svg.appendChild(svgEl('line', { x1: cx, y1: cy, x2: p.x, y2: p.y, stroke: GRID_COLOR, 'stroke-width': 1 }));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Spoke labels.
|
|
513
|
+
for (let i = 0; i < N; i++) {
|
|
514
|
+
const p = polar(cx, cy, R + 12, angleAt(i));
|
|
515
|
+
const raw = axisKey ? data[i][axisKey] : null;
|
|
516
|
+
svg.appendChild(svgText({ x: p.x, y: p.y + 3, 'text-anchor': 'middle', 'font-size': AXIS_FONT, fill: AXIS_COLOR }, raw != null ? String(raw) : ''));
|
|
500
517
|
}
|
|
501
|
-
wrapper
|
|
518
|
+
const ttCtx = showTooltipProp ? createTooltip(wrapper, svg) : undefined;
|
|
519
|
+
const valueFmt = resolveValueFormat(node);
|
|
520
|
+
const fmt = (v) => valueFmt ? applyPipeFormat(v, valueFmt, ctx) : String(v);
|
|
521
|
+
// One polygon per series.
|
|
522
|
+
for (let si = 0; si < series.length; si++) {
|
|
523
|
+
const s = series[si];
|
|
524
|
+
const color = s.color ?? COLORS[si % COLORS.length];
|
|
525
|
+
const pts = data.map((d, i) => polar(cx, cy, (Number(d[s.dataKey] ?? 0) / max) * R, angleAt(i)));
|
|
526
|
+
svg.appendChild(svgEl('polygon', {
|
|
527
|
+
points: ptStr(pts),
|
|
528
|
+
fill: filled ? color : 'none', 'fill-opacity': filled ? 0.2 : 0,
|
|
529
|
+
stroke: color, 'stroke-width': 2,
|
|
530
|
+
}));
|
|
531
|
+
if (showDots || ttCtx) {
|
|
532
|
+
for (let i = 0; i < pts.length; i++) {
|
|
533
|
+
const dot = svgEl('circle', {
|
|
534
|
+
cx: pts[i].x, cy: pts[i].y, r: showDots ? 3 : 6,
|
|
535
|
+
fill: showDots ? color : 'transparent', 'fill-opacity': showDots ? 1 : 0,
|
|
536
|
+
});
|
|
537
|
+
if (ttCtx) {
|
|
538
|
+
const raw = axisKey ? data[i][axisKey] : null;
|
|
539
|
+
const title = raw != null ? String(raw) : (s.label ?? s.dataKey);
|
|
540
|
+
const entries = [{ label: s.label ?? s.dataKey, value: fmt(Number(data[i][s.dataKey] ?? 0)), color }];
|
|
541
|
+
dot.addEventListener('mouseenter', () => showTooltipAt(ttCtx, pts[i].x, pts[i].y, title, entries));
|
|
542
|
+
dot.addEventListener('mouseleave', () => ttCtx.tooltip.classList.remove('pf-visible'));
|
|
543
|
+
dot.addEventListener('touchstart', () => showTooltipAt(ttCtx, pts[i].x, pts[i].y, title, entries), { passive: true });
|
|
544
|
+
dot.addEventListener('touchend', () => ttCtx.tooltip.classList.remove('pf-visible'), { passive: true });
|
|
545
|
+
}
|
|
546
|
+
svg.appendChild(dot);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
addLegend(wrapper, series, node.showLegend);
|
|
551
|
+
wrapper.appendChild(svg);
|
|
552
|
+
return wrapper;
|
|
502
553
|
}
|
|
503
554
|
// ── Histogram ────────────────────────────────────────────────────────────────
|
|
504
555
|
function renderHistogram(node, ctx) {
|