@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/dist/app.d.ts +24 -2
  4. package/dist/app.d.ts.map +1 -1
  5. package/dist/app.js +46 -9
  6. package/dist/app.js.map +1 -1
  7. package/dist/components/charts/index.d.ts +56 -4
  8. package/dist/components/charts/index.d.ts.map +1 -1
  9. package/dist/components/charts/index.js +33 -7
  10. package/dist/components/charts/index.js.map +1 -1
  11. package/dist/core/theme-css.d.ts +32 -0
  12. package/dist/core/theme-css.d.ts.map +1 -0
  13. package/dist/core/theme-css.js +55 -0
  14. package/dist/core/theme-css.js.map +1 -0
  15. package/dist/core/validate.d.ts.map +1 -1
  16. package/dist/core/validate.js +24 -1
  17. package/dist/core/validate.js.map +1 -1
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp/display.d.ts +6 -2
  23. package/dist/mcp/display.d.ts.map +1 -1
  24. package/dist/mcp/display.js +8 -2
  25. package/dist/mcp/display.js.map +1 -1
  26. package/dist/renderer/components/chart-helpers.d.ts +63 -0
  27. package/dist/renderer/components/chart-helpers.d.ts.map +1 -0
  28. package/dist/renderer/components/chart-helpers.js +202 -0
  29. package/dist/renderer/components/chart-helpers.js.map +1 -0
  30. package/dist/renderer/components/chart-tooltip.d.ts +1 -1
  31. package/dist/renderer/components/chart-tooltip.d.ts.map +1 -1
  32. package/dist/renderer/components/charts.d.ts +4 -20
  33. package/dist/renderer/components/charts.d.ts.map +1 -1
  34. package/dist/renderer/components/charts.js +246 -195
  35. package/dist/renderer/components/charts.js.map +1 -1
  36. package/dist/renderer/index.d.ts +6 -0
  37. package/dist/renderer/index.d.ts.map +1 -1
  38. package/dist/renderer/index.js +65 -24
  39. package/dist/renderer/index.js.map +1 -1
  40. package/dist/renderer/theme.d.ts +15 -0
  41. package/dist/renderer/theme.d.ts.map +1 -1
  42. package/dist/renderer/theme.js +26 -18
  43. package/dist/renderer/theme.js.map +1 -1
  44. package/dist/renderer.auto.min.js +14 -12
  45. package/dist/renderer.min.js +14 -12
  46. package/package.json +1 -1
@@ -1,155 +1,24 @@
1
1
  /**
2
- * Chart component renderers — BarChart, LineChart, AreaChart, PieChart, etc.
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
- * For production use, these can be enhanced with a charting library.
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', renderFallbackChart);
15
- registerComponent('ScatterChart', renderFallbackChart);
16
- registerComponent('RadialChart', renderFallbackChart);
16
+ registerComponent('RadarChart', renderRadarChart);
17
+ registerComponent('ScatterChart', renderScatterChart);
18
+ registerComponent('RadialChart', renderRadialChart);
17
19
  registerComponent('Histogram', renderHistogram);
18
20
  }
19
- const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
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.yAxisFormat);
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.yAxisFormat, node.yAxisRightFormat);
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.yAxisFormat);
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.yAxisFormat, node.yAxisRightFormat);
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 series = node.series ?? [];
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
- if (data.length === 0 || series.length === 0) {
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 sliceKey = tooltipXKey ?? xAxisKey;
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
- function renderFallbackChart(node, _ctx) {
458
- const e = el('div', `pf-chart pf-${node.type.toLowerCase()}`);
459
- e.textContent = `${node.type} not yet supported in renderer`;
460
- e.style.padding = '24px';
461
- e.style.textAlign = 'center';
462
- return e;
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
- // ── Helpers ──────────────────────────────────────────────────────────────────
465
- function createSvg(width, height, chartType) {
466
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
467
- svg.setAttribute('width', '100%');
468
- svg.setAttribute('height', String(height));
469
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
470
- svg.setAttribute('role', 'img');
471
- if (chartType) {
472
- svg.setAttribute('aria-label', `${chartType} chart`);
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
- svg.style.overflow = 'visible';
475
- return svg;
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
- function addLegend(wrapper, series, show) {
478
- if (show === false || series.length <= 1)
479
- return;
480
- const legend = el('div', 'pf-chart-legend');
481
- legend.style.display = 'flex';
482
- legend.style.gap = '12px';
483
- legend.style.fontSize = '12px';
484
- legend.style.marginBottom = '8px';
485
- for (let i = 0; i < series.length; i++) {
486
- const item = el('div', 'pf-chart-legend-item');
487
- item.style.display = 'flex';
488
- item.style.alignItems = 'center';
489
- item.style.gap = '4px';
490
- const dot = el('span');
491
- dot.style.width = '8px';
492
- dot.style.height = '8px';
493
- dot.style.borderRadius = '50%';
494
- dot.style.backgroundColor = series[i].color ?? COLORS[i % COLORS.length];
495
- const label = el('span');
496
- label.textContent = series[i].label ?? series[i].dataKey;
497
- item.appendChild(dot);
498
- item.appendChild(label);
499
- legend.appendChild(item);
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.appendChild(legend);
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) {