@skdx/angular-charts 0.33.0 → 0.34.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 (65) hide show
  1. package/README.md +96 -41
  2. package/fesm2022/skdx-angular-charts-area.mjs +14 -2
  3. package/fesm2022/skdx-angular-charts-area.mjs.map +1 -1
  4. package/fesm2022/skdx-angular-charts-bar.mjs +18 -15
  5. package/fesm2022/skdx-angular-charts-bar.mjs.map +1 -1
  6. package/fesm2022/skdx-angular-charts-boxplot.mjs +18 -6
  7. package/fesm2022/skdx-angular-charts-boxplot.mjs.map +1 -1
  8. package/fesm2022/skdx-angular-charts-candlestick.mjs +41 -8
  9. package/fesm2022/skdx-angular-charts-candlestick.mjs.map +1 -1
  10. package/fesm2022/skdx-angular-charts-chord.mjs +18 -6
  11. package/fesm2022/skdx-angular-charts-chord.mjs.map +1 -1
  12. package/fesm2022/skdx-angular-charts-funnel.mjs +29 -8
  13. package/fesm2022/skdx-angular-charts-funnel.mjs.map +1 -1
  14. package/fesm2022/skdx-angular-charts-gantt.mjs +23 -11
  15. package/fesm2022/skdx-angular-charts-gantt.mjs.map +1 -1
  16. package/fesm2022/skdx-angular-charts-heatmap.mjs +53 -15
  17. package/fesm2022/skdx-angular-charts-heatmap.mjs.map +1 -1
  18. package/fesm2022/skdx-angular-charts-internal.mjs +2709 -494
  19. package/fesm2022/skdx-angular-charts-internal.mjs.map +1 -1
  20. package/fesm2022/skdx-angular-charts-line.mjs +14 -2
  21. package/fesm2022/skdx-angular-charts-line.mjs.map +1 -1
  22. package/fesm2022/skdx-angular-charts-map.mjs +35 -8
  23. package/fesm2022/skdx-angular-charts-map.mjs.map +1 -1
  24. package/fesm2022/skdx-angular-charts-pie.mjs +37 -5
  25. package/fesm2022/skdx-angular-charts-pie.mjs.map +1 -1
  26. package/fesm2022/skdx-angular-charts-radar.mjs +35 -8
  27. package/fesm2022/skdx-angular-charts-radar.mjs.map +1 -1
  28. package/fesm2022/skdx-angular-charts-radial.mjs +108 -0
  29. package/fesm2022/skdx-angular-charts-radial.mjs.map +1 -0
  30. package/fesm2022/skdx-angular-charts-sankey.mjs +18 -6
  31. package/fesm2022/skdx-angular-charts-sankey.mjs.map +1 -1
  32. package/fesm2022/skdx-angular-charts-scatter.mjs +37 -5
  33. package/fesm2022/skdx-angular-charts-scatter.mjs.map +1 -1
  34. package/fesm2022/skdx-angular-charts-sparkline.mjs +42 -8
  35. package/fesm2022/skdx-angular-charts-sparkline.mjs.map +1 -1
  36. package/fesm2022/skdx-angular-charts-sunburst.mjs +18 -6
  37. package/fesm2022/skdx-angular-charts-sunburst.mjs.map +1 -1
  38. package/fesm2022/skdx-angular-charts-treemap.mjs +26 -6
  39. package/fesm2022/skdx-angular-charts-treemap.mjs.map +1 -1
  40. package/fesm2022/skdx-angular-charts-waterfall.mjs +18 -6
  41. package/fesm2022/skdx-angular-charts-waterfall.mjs.map +1 -1
  42. package/fesm2022/skdx-angular-charts.mjs +2 -0
  43. package/fesm2022/skdx-angular-charts.mjs.map +1 -1
  44. package/package.json +7 -3
  45. package/types/skdx-angular-charts-area.d.ts +1 -1
  46. package/types/skdx-angular-charts-bar.d.ts +5 -13
  47. package/types/skdx-angular-charts-boxplot.d.ts +2 -2
  48. package/types/skdx-angular-charts-candlestick.d.ts +10 -4
  49. package/types/skdx-angular-charts-chord.d.ts +2 -2
  50. package/types/skdx-angular-charts-funnel.d.ts +5 -3
  51. package/types/skdx-angular-charts-gantt.d.ts +6 -6
  52. package/types/skdx-angular-charts-heatmap.d.ts +16 -8
  53. package/types/skdx-angular-charts-internal.d.ts +581 -44
  54. package/types/skdx-angular-charts-line.d.ts +1 -1
  55. package/types/skdx-angular-charts-map.d.ts +4 -3
  56. package/types/skdx-angular-charts-pie.d.ts +10 -2
  57. package/types/skdx-angular-charts-radar.d.ts +8 -3
  58. package/types/skdx-angular-charts-radial.d.ts +34 -0
  59. package/types/skdx-angular-charts-sankey.d.ts +2 -2
  60. package/types/skdx-angular-charts-scatter.d.ts +10 -3
  61. package/types/skdx-angular-charts-sparkline.d.ts +13 -5
  62. package/types/skdx-angular-charts-sunburst.d.ts +2 -2
  63. package/types/skdx-angular-charts-treemap.d.ts +4 -4
  64. package/types/skdx-angular-charts-waterfall.d.ts +2 -2
  65. package/types/skdx-angular-charts.d.ts +2 -1
@@ -1,5 +1,83 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, ChangeDetectionStrategy, Component, signal, Directive } from '@angular/core';
2
+ import { input, ChangeDetectionStrategy, Component, output, computed, viewChild, signal, inject, ElementRef, DestroyRef, afterNextRender, model, Directive } from '@angular/core';
3
+
4
+ /*
5
+ * Copyright (c) 2026 SkandaDX
6
+ * This file is part of the SkandaDX organization.
7
+ * Licensed under the MIT License. See LICENSE in the repository root.
8
+ */
9
+ const round$1 = (v) => Number(v.toPrecision(12));
10
+ let defaultNumberFormat;
11
+ /** The default: the runtime locale's plain number format, with float noise trimmed. */
12
+ const formatValue = (v) => (defaultNumberFormat ??= new Intl.NumberFormat()).format(round$1(v));
13
+ /** Seconds as `1h 02m`, `3m 05s` or `12s`. */
14
+ const duration = (seconds) => {
15
+ const s = Math.round(Math.abs(seconds));
16
+ const sign = seconds < 0 ? '-' : '';
17
+ const h = Math.floor(s / 3600);
18
+ const m = Math.floor((s % 3600) / 60);
19
+ const pad = (n) => String(n).padStart(2, '0');
20
+ if (h > 0)
21
+ return `${sign}${h}h ${pad(m)}m`;
22
+ if (m > 0)
23
+ return `${sign}${m}m ${pad(s % 60)}s`;
24
+ return `${sign}${s}s`;
25
+ };
26
+ /** Turns a `Format` into a function; `undefined` gives the default number format. */
27
+ function makeFormatter(format) {
28
+ if (format === undefined)
29
+ return formatValue;
30
+ if (typeof format === 'function')
31
+ return format;
32
+ const { style, locale, digits, unit = '' } = format;
33
+ const withUnit = (f) => (unit ? (v) => f(v) + unit : f);
34
+ const digitsOf = (fallback) => digits === undefined
35
+ ? fallback === undefined
36
+ ? {}
37
+ : { maximumFractionDigits: fallback }
38
+ : { minimumFractionDigits: digits, maximumFractionDigits: digits };
39
+ const number = (options) => {
40
+ const nf = new Intl.NumberFormat(locale, options);
41
+ return withUnit((v) => nf.format(round$1(v)));
42
+ };
43
+ const date = (options) => {
44
+ const df = new Intl.DateTimeFormat(locale, options);
45
+ return (v) => df.format(v);
46
+ };
47
+ switch (style) {
48
+ case 'integer':
49
+ return number({ maximumFractionDigits: 0 });
50
+ case 'decimal':
51
+ return number(digitsOf(2));
52
+ case 'percent':
53
+ return number({ style: 'percent', ...digitsOf(0) });
54
+ case 'currency':
55
+ return number({ style: 'currency', currency: format.currency ?? 'USD', ...digitsOf() });
56
+ case 'compact':
57
+ return number({ notation: 'compact', ...digitsOf(1) });
58
+ case 'scientific':
59
+ return number({ notation: 'scientific', ...digitsOf(2) });
60
+ case 'date':
61
+ return date({ dateStyle: 'medium' });
62
+ case 'time':
63
+ return date({ timeStyle: 'short' });
64
+ case 'datetime':
65
+ return date({ dateStyle: 'medium', timeStyle: 'short' });
66
+ case 'duration':
67
+ return withUnit(duration);
68
+ }
69
+ }
70
+ /** A category as axis text: strings as they are, numbers and dates through `format`. */
71
+ function categoryLabel(c, format) {
72
+ if (typeof c === 'string')
73
+ return c;
74
+ const v = c instanceof Date ? c.getTime() : c;
75
+ if (format)
76
+ return makeFormatter(format)(v);
77
+ return c instanceof Date ? new Intl.DateTimeFormat().format(c) : formatValue(v);
78
+ }
79
+ /** A category as a number, for positioning on numeric axes (Gantt, scatter references). */
80
+ const categoryValue = (c) => c instanceof Date ? c.getTime() : typeof c === 'number' ? c : Number(c);
3
81
 
4
82
  /*
5
83
  * Copyright (c) 2026 SkandaDX
@@ -24,9 +102,14 @@ const CHART_COLORS = {
24
102
  axis: 'var(--skdx-color-border-strong)',
25
103
  grid: 'var(--skdx-color-border-subtle)',
26
104
  label: 'var(--skdx-color-text-muted)',
105
+ focus: 'var(--skdx-color-focus-ring)',
106
+ positive: 'var(--skdx-color-success-500)',
107
+ negative: 'var(--skdx-color-danger-500)',
27
108
  fontFamily: 'var(--skdx-font-family)',
28
109
  fontSize: 'var(--skdx-font-size-xs)',
29
110
  };
111
+ /** Semantic color of a reference line, band or threshold sector. */
112
+ const variantColor = (variant = 'neutral') => variant === 'neutral' ? CHART_COLORS.axis : `var(--skdx-color-${variant}-500)`;
30
113
  const seriesColor = (index, override) => override ?? SERIES_COLORS[index % SERIES_COLORS.length];
31
114
 
32
115
  /*
@@ -68,12 +151,43 @@ function bandScale(count, [r0, r1]) {
68
151
  const round = (v) => Number(v.toPrecision(12));
69
152
  /** Pixel coordinates rounded to 2 decimals, so the markup stays readable. */
70
153
  const px = (n) => Number(n.toFixed(2));
71
- const formatValue = (v) => new Intl.NumberFormat().format(round(v));
72
- /** SVG path through the points: straight segments, or a monotone cubic (Fritsch–Carlson) that never overshoots. */
154
+ /** `true` for a plottable number: not `null`, `NaN` or infinite. */
155
+ const isFinite = (v) => typeof v === 'number' && Number.isFinite(v);
156
+ /** The plottable numbers of a list, in order. */
157
+ const finite = (values) => values.filter(isFinite);
158
+ /** `[min, max]` over any number of values without spreading them as arguments; `[0, 0]` when empty. */
159
+ function minMax(values) {
160
+ let min = Infinity;
161
+ let max = -Infinity;
162
+ for (const v of values) {
163
+ if (!isFinite(v))
164
+ continue;
165
+ if (v < min)
166
+ min = v;
167
+ if (v > max)
168
+ max = v;
169
+ }
170
+ return min === Infinity ? [0, 0] : [min, max];
171
+ }
172
+ /** SVG path through the points: straight segments, steps, or a monotone cubic (Fritsch–Carlson) that never overshoots. */
73
173
  function pathThrough(points, curve = 'linear') {
74
174
  if (points.length === 0)
75
175
  return '';
76
176
  const first = points[0];
177
+ if (curve === 'step' || curve === 'stepBefore' || curve === 'stepAfter') {
178
+ let d = `M${first.x} ${first.y}`;
179
+ for (let i = 1; i < points.length; i++) {
180
+ const a = points[i - 1];
181
+ const b = points[i];
182
+ if (curve === 'stepAfter')
183
+ d += `H${b.x}V${b.y}`;
184
+ else if (curve === 'stepBefore')
185
+ d += `V${b.y}H${b.x}`;
186
+ else
187
+ d += `H${px((a.x + b.x) / 2)}V${b.y}H${b.x}`;
188
+ }
189
+ return d;
190
+ }
77
191
  if (curve === 'linear' || points.length < 3)
78
192
  return `M${points.map((p) => `${p.x} ${p.y}`).join('L')}`;
79
193
  const n = points.length;
@@ -111,6 +225,60 @@ function pathThrough(points, curve = 'linear') {
111
225
  }
112
226
  return d;
113
227
  }
228
+ const SECOND = 1000;
229
+ const MINUTE = 60 * SECOND;
230
+ const HOUR = 60 * MINUTE;
231
+ const DAY$1 = 24 * HOUR;
232
+ /** Calendar-friendly tick steps, in ms, from a second up to a week. */
233
+ const TIME_STEPS = [
234
+ SECOND,
235
+ 5 * SECOND,
236
+ 15 * SECOND,
237
+ 30 * SECOND,
238
+ MINUTE,
239
+ 5 * MINUTE,
240
+ 15 * MINUTE,
241
+ 30 * MINUTE,
242
+ HOUR,
243
+ 3 * HOUR,
244
+ 6 * HOUR,
245
+ 12 * HOUR,
246
+ DAY$1,
247
+ 2 * DAY$1,
248
+ 7 * DAY$1,
249
+ 14 * DAY$1,
250
+ ];
251
+ /**
252
+ * Round timestamps covering `[min, max]` (epoch ms), about `count` of them: seconds to weeks as
253
+ * UTC-aligned multiples, then whole months or years.
254
+ */
255
+ function timeTicks(min, max, count = 6) {
256
+ if (!(max > min))
257
+ return [min];
258
+ const span = max - min;
259
+ const step = TIME_STEPS.find((s) => span / s <= count);
260
+ const ticks = [];
261
+ if (step !== undefined) {
262
+ for (let t = Math.ceil(min / step) * step; t <= max; t += step)
263
+ ticks.push(t);
264
+ return ticks.length ? ticks : [min, max];
265
+ }
266
+ const months = span / (30 * DAY$1);
267
+ const stepMonths = [1, 2, 3, 6, 12, 24, 60, 120].find((m) => months / m <= count) ?? 240;
268
+ const d = new Date(min);
269
+ let y = d.getUTCFullYear();
270
+ let m = Math.ceil((d.getUTCMonth() + (d.getUTCDate() > 1 ? 1 : 0)) / stepMonths) * stepMonths;
271
+ for (let t = Date.UTC(y, m, 1); t <= max;) {
272
+ ticks.push(t);
273
+ m += stepMonths;
274
+ if (m >= 12) {
275
+ y += Math.floor(m / 12);
276
+ m %= 12;
277
+ }
278
+ t = Date.UTC(y, m, 1);
279
+ }
280
+ return ticks.length ? ticks : [min, max];
281
+ }
114
282
  /** Ticks at powers of ten covering `[min, max]` (both > 0), with 2× and 5× steps when a decade or less. */
115
283
  function logTicks(min, max) {
116
284
  const lo = Math.floor(Math.log10(min));
@@ -144,9 +312,16 @@ const size = (input, w = 600, h = 300) => ({
144
312
  const LEGEND_HEIGHT = 20;
145
313
  /** Vertical room the legend row takes, to shift the plot down by. */
146
314
  const legendTop = (input) => (input.legend ? LEGEND_HEIGHT : 0);
315
+ /** Average glyph width at the chart font size, for text-width estimates. */
147
316
  const CHAR = 6.5;
317
+ /** The series color for index `i`: an explicit override, else the chart palette, else the tokens. */
318
+ const colorOf = (input, i, override) => override ??
319
+ (input.palette && input.palette.length > 0
320
+ ? input.palette[i % input.palette.length]
321
+ : seriesColor(i));
322
+ const isHidden = (input, name) => input.hiddenSeries?.includes(name) ?? false;
148
323
  /** One row of swatches and names along the top edge, wrapping when it runs out of width. */
149
- function legendShapes(items, width) {
324
+ function legendShapes(items, width, hidden = []) {
150
325
  const shapes = [];
151
326
  let x = 8;
152
327
  let y = 10;
@@ -156,32 +331,50 @@ function legendShapes(items, width) {
156
331
  x = 8;
157
332
  y += LEGEND_HEIGHT;
158
333
  }
159
- shapes.push({ kind: 'rect', part: 'legend-swatch', x, y: y - 5, width: 10, height: 10, fill: item.color }, {
334
+ const off = hidden.includes(item.name);
335
+ const meta = { label: item.name, series: item.name, legend: true };
336
+ shapes.push({
337
+ kind: 'rect',
338
+ part: 'legend-swatch',
339
+ x,
340
+ y: y - 5,
341
+ width: 10,
342
+ height: 10,
343
+ fill: item.color,
344
+ opacity: off ? 0.3 : undefined,
345
+ item: meta,
346
+ }, {
160
347
  kind: 'text',
161
348
  part: 'legend-label',
162
349
  x: x + 14,
163
350
  y,
164
351
  text: item.name,
165
352
  baseline: 'middle',
166
- fill: CHART_COLORS.label,
353
+ fill: off ? CHART_COLORS.grid : CHART_COLORS.label,
354
+ item: meta,
167
355
  });
168
356
  x += w + 12;
169
357
  }
170
358
  return shapes;
171
359
  }
172
- /** The common tail of every layout: size, legend decorations and the tooltip switch. */
360
+ /** The common tail of every layout: size, legend decorations, the tooltip switch and accessibility passthroughs. */
173
361
  function canvas(input, width, height, legend = []) {
174
362
  return {
175
363
  width,
176
364
  height,
177
365
  tooltip: input.tooltip ?? true,
178
- shapes: input.legend ? legendShapes(legend, width) : [],
366
+ description: input.description,
367
+ decorative: input.decorative,
368
+ animate: input.animate === true ? 300 : input.animate || undefined,
369
+ responsive: input.responsive,
370
+ shapes: input.legend ? legendShapes(legend, width, input.hiddenSeries ?? []) : [],
179
371
  };
180
372
  }
181
- /** A tooltip box next to the pointer, kept inside the canvas. */
373
+ /** A tooltip box next to the pointer, kept inside the canvas; one line per entry. */
182
374
  function tooltipShapes(x, y, text, width, height) {
183
- const w = text.length * CHAR + 12;
184
- const h = 22;
375
+ const lines = Array.isArray(text) ? text : [text];
376
+ const w = Math.max(...lines.map((l) => l.length)) * CHAR + 12;
377
+ const h = 6 + lines.length * 16;
185
378
  const bx = Math.max(0, Math.min(width - w, x + 12));
186
379
  const by = Math.max(0, Math.min(height - h, y - h - 6));
187
380
  return [
@@ -194,89 +387,113 @@ function tooltipShapes(x, y, text, width, height) {
194
387
  height: h,
195
388
  fill: 'var(--skdx-color-surface-raised)',
196
389
  stroke: CHART_COLORS.axis,
390
+ rx: 3,
197
391
  },
198
- {
392
+ ...lines.map((line, i) => ({
199
393
  kind: 'text',
200
394
  part: 'tooltip-label',
201
395
  x: px(bx + 6),
202
- y: px(by + h / 2),
203
- text,
396
+ y: px(by + 11 + i * 16),
397
+ text: line,
204
398
  baseline: 'middle',
205
399
  fill: 'var(--skdx-color-text-default)',
206
- },
400
+ weight: lines.length > 1 && i === 0 ? 'bold' : undefined,
401
+ })),
207
402
  ];
208
403
  }
209
- /** `[min, max]` of the values, widened to include zero when asked; `[0, 0]` when empty. */
404
+ /** `[min, max]` of the plottable values, widened to include zero when asked; `[0, 0]` when empty. */
210
405
  function extent(values, includeZero) {
211
- if (values.length === 0)
212
- return [0, 0];
213
- const min = Math.min(...values);
214
- const max = Math.max(...values);
406
+ const [min, max] = minMax(values);
215
407
  return includeZero ? [Math.min(0, min), Math.max(0, max)] : [min, max];
216
408
  }
217
- /** A nice-ticked linear axis over `[min, max]` mapped onto `range`. */
218
- function axis([min, max], range, log = false) {
219
- if (log) {
220
- const lo = Math.max(min, Number.EPSILON);
221
- const ticks = logTicks(lo, Math.max(max, lo));
222
- const domain = [Math.log10(lo), Math.log10(Math.max(max, lo))];
409
+ /** A nice-ticked linear (or log) axis over `[min, max]` mapped onto `range`, honouring explicit bounds. */
410
+ function axis([min, max], range, options = false, chartFormat) {
411
+ const opts = typeof options === 'boolean' ? { scale: options ? 'log' : 'linear' } : options;
412
+ const format = makeFormatter(opts.format ?? chartFormat);
413
+ const lo = opts.min ?? min;
414
+ const hi = opts.max ?? max;
415
+ if (opts.scale === 'log') {
416
+ const l = Math.max(lo, Number.EPSILON);
417
+ const h = Math.max(hi, l);
418
+ const ticks = logTicks(l, h);
419
+ const domain = [Math.log10(l), Math.log10(h)];
223
420
  const lin = linearScale(domain, range);
224
421
  const scale = (v) => lin(Math.log10(Math.max(v, Number.EPSILON)));
225
422
  return {
226
423
  scale,
227
424
  domain,
228
- ticks: ticks.map((v) => ({ pos: px(scale(v)), label: formatValue(v) })),
425
+ format,
426
+ ticks: ticks.map((v) => ({ pos: px(scale(v)), label: format(v) })),
229
427
  zero: px(range[0]),
230
428
  };
231
429
  }
232
- const ticks = niceTicks(min, max);
233
- const domain = [ticks[0], ticks[ticks.length - 1]];
430
+ const nice = niceTicks(lo, hi);
431
+ // Explicit bounds win over the rounded ends; ticks outside them are dropped.
432
+ const domain = [
433
+ opts.min ?? nice[0],
434
+ opts.max ?? nice[nice.length - 1],
435
+ ];
234
436
  const scale = linearScale(domain, range);
437
+ const inside = nice.filter((v) => v >= domain[0] - 1e-9 && v <= domain[1] + 1e-9);
438
+ const ticks = (inside.length >= 2 ? inside : [domain[0], domain[1]]).map((v) => ({
439
+ pos: px(scale(v)),
440
+ label: format(v),
441
+ }));
235
442
  return {
236
443
  scale,
237
444
  domain,
238
- ticks: ticks.map((v) => ({ pos: px(scale(v)), label: formatValue(v) })),
239
- /** Pixel position of zero, clamped into the domain. */
445
+ format,
446
+ ticks,
240
447
  zero: px(scale(Math.min(Math.max(0, domain[0]), domain[1]))),
241
448
  };
242
449
  }
243
- /** Axes, ticks and scales shared by the category charts; `yExtent` is the y domain to cover. */
244
- function buildFrame(input, yExtent) {
245
- const { width, height } = size(input);
246
- const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
247
- const count = Math.max(input.categories?.length ?? 0, ...input.series.map((s) => s.data.length));
248
- const categories = Array.from({ length: count }, (_, i) => input.categories?.[i] ?? String(i + 1));
249
- const y = axis(yExtent, [plot.y + plot.height, plot.y]);
250
- const x = bandScale(count, [plot.x, plot.x + plot.width]);
251
- return {
252
- frame: {
253
- ...canvas(input, width, height, input.series.map((s, i) => ({ name: s.name, color: seriesColor(i, s.color) }))),
254
- plot,
255
- xTicks: categories.map((label, i) => ({ pos: px(x.center(i)), label })),
256
- yTicks: y.ticks,
257
- baseline: y.zero,
258
- },
259
- x,
260
- y: y.scale,
261
- categories,
262
- };
450
+ /** Keeps every n-th tick so labels of the given widths do not overlap along `width`. */
451
+ function thinTicks(ticks, width) {
452
+ if (ticks.length < 2)
453
+ return ticks;
454
+ const widest = ticks.reduce((w, t) => Math.max(w, t.label.length), 0) * CHAR + 8;
455
+ const every = Math.max(1, Math.ceil((ticks.length * widest) / Math.max(1, width)));
456
+ return every === 1 ? ticks : ticks.filter((_, i) => i % every === 0);
263
457
  }
264
- const values = (input) => input.series.flatMap((s) => s.data);
265
458
  /** Tooltip text for a value in a series: `Series, Category: 1,234`. */
266
- const markTitle = (series, category, value) => `${series}, ${category}: ${formatValue(value)}`;
459
+ const markTitle = (series, category, value, format) => `${series}, ${category}: ${format(value)}`;
267
460
  /**
268
- * Tooltip shapes for the mark under the pointer, or none. Reads the mark's
269
- * `<title>` and converts the pointer position into viewBox units.
461
+ * The loading, error or empty placeholder, or `null` when the chart should draw. Charts call this
462
+ * first so no axis is drawn around missing data.
270
463
  */
271
- function hoverTooltip(target, svg, clientX, clientY, width, height) {
272
- const mark = target?.closest?.('[data-skdx-part]');
273
- const title = mark && Array.from(mark.children).find((c) => c.tagName.toLowerCase() === 'title')?.textContent;
274
- if (!title)
275
- return [];
276
- const r = svg.getBoundingClientRect();
277
- const x = r.width ? ((clientX - r.left) * width) / r.width : clientX;
278
- const y = r.height ? ((clientY - r.top) * height) / r.height : clientY;
279
- return tooltipShapes(x, y, title, width, height);
464
+ function stateCanvas(input, empty, w, h) {
465
+ const state = input.error !== undefined ? 'error' : input.loading ? 'loading' : empty ? 'empty' : null;
466
+ if (!state)
467
+ return null;
468
+ const { width, height } = size(input, w, h);
469
+ const text = state === 'error'
470
+ ? input.error
471
+ : state === 'loading'
472
+ ? 'Loading…'
473
+ : (input.emptyMessage ?? 'No data available');
474
+ const shapes = [];
475
+ if (state === 'loading')
476
+ shapes.push({
477
+ kind: 'rect',
478
+ part: 'skeleton',
479
+ x: 0,
480
+ y: 0,
481
+ width,
482
+ height,
483
+ fill: 'var(--skdx-color-surface-sunken)',
484
+ rx: 4,
485
+ });
486
+ shapes.push({
487
+ kind: 'text',
488
+ part: `state-${state}`,
489
+ x: px(width / 2),
490
+ y: px(height / 2),
491
+ text,
492
+ anchor: 'middle',
493
+ baseline: 'middle',
494
+ fill: state === 'error' ? 'var(--skdx-color-text-danger)' : CHART_COLORS.label,
495
+ });
496
+ return { ...canvas(input, width, height), shapes, tooltip: false };
280
497
  }
281
498
 
282
499
  /*
@@ -285,20 +502,49 @@ function hoverTooltip(target, svg, clientX, clientY, width, height) {
285
502
  * Licensed under the MIT License. See LICENSE in the repository root.
286
503
  */
287
504
  /** Grid, axis line and tick labels of an axis frame, as shapes. */
288
- function frameShapes(frame) {
505
+ function frameShapes(frame, options = {}) {
289
506
  const { plot, xTicks, yTicks, baseline } = frame;
290
507
  const right = plot.x + plot.width;
291
- return [
292
- ...yTicks.map((t) => ({
508
+ const bottom = plot.y + plot.height;
509
+ const shapes = [];
510
+ if (options.horizontal) {
511
+ // Value ticks along the bottom with vertical grid lines; category labels on the left.
512
+ for (const t of yTicks)
513
+ shapes.push({
514
+ kind: 'line',
515
+ part: 'grid',
516
+ x1: t.pos,
517
+ x2: t.pos,
518
+ y1: plot.y,
519
+ y2: bottom,
520
+ stroke: CHART_COLORS.grid,
521
+ });
522
+ shapes.push({
293
523
  kind: 'line',
294
- part: 'grid',
295
- x1: plot.x,
296
- x2: right,
297
- y1: t.pos,
298
- y2: t.pos,
299
- stroke: CHART_COLORS.grid,
300
- })),
301
- {
524
+ part: 'axis',
525
+ x1: baseline,
526
+ x2: baseline,
527
+ y1: plot.y,
528
+ y2: bottom,
529
+ stroke: CHART_COLORS.axis,
530
+ });
531
+ for (const t of yTicks)
532
+ shapes.push(label(t.pos, bottom + 20, t.label, { part: 'x-label', anchor: 'middle' }));
533
+ for (const t of xTicks)
534
+ shapes.push(label(plot.x - 8, t.pos, t.label, { part: 'y-label', anchor: 'end', baseline: 'middle' }));
535
+ }
536
+ else {
537
+ for (const t of yTicks)
538
+ shapes.push({
539
+ kind: 'line',
540
+ part: 'grid',
541
+ x1: plot.x,
542
+ x2: right,
543
+ y1: t.pos,
544
+ y2: t.pos,
545
+ stroke: CHART_COLORS.grid,
546
+ });
547
+ shapes.push({
302
548
  kind: 'line',
303
549
  part: 'axis',
304
550
  x1: plot.x,
@@ -306,109 +552,165 @@ function frameShapes(frame) {
306
552
  y1: baseline,
307
553
  y2: baseline,
308
554
  stroke: CHART_COLORS.axis,
309
- },
310
- ...yTicks.map((t) => ({
311
- kind: 'text',
312
- part: 'y-label',
313
- x: plot.x - 8,
314
- y: t.pos,
315
- text: t.label,
316
- anchor: 'end',
317
- baseline: 'middle',
318
- fill: CHART_COLORS.label,
319
- })),
320
- ...xTicks.map((t) => ({
321
- kind: 'text',
322
- part: 'x-label',
323
- x: t.pos,
324
- y: plot.y + plot.height + 20,
325
- text: t.label,
555
+ });
556
+ for (const t of yTicks)
557
+ shapes.push(label(plot.x - 8, t.pos, t.label, { part: 'y-label', anchor: 'end', baseline: 'middle' }));
558
+ for (const t of options.rightTicks ?? [])
559
+ shapes.push(label(right + 8, t.pos, t.label, {
560
+ part: 'y-label-right',
561
+ anchor: 'start',
562
+ baseline: 'middle',
563
+ }));
564
+ for (const t of xTicks)
565
+ shapes.push(label(t.pos, bottom + 20, t.label, { part: 'x-label', anchor: 'middle' }));
566
+ }
567
+ if (options.xLabel)
568
+ shapes.push(label(px(plot.x + plot.width / 2), frame.height - 4, options.xLabel, {
569
+ part: 'axis-title',
326
570
  anchor: 'middle',
327
- fill: CHART_COLORS.label,
328
- })),
329
- ];
571
+ }));
572
+ if (options.yLabel)
573
+ shapes.push(label(4, plot.y - 6, options.yLabel, { part: 'axis-title', anchor: 'start' }));
574
+ if (options.rightLabel)
575
+ shapes.push(label(frame.width - 4, plot.y - 6, options.rightLabel, { part: 'axis-title', anchor: 'end' }));
576
+ return shapes;
330
577
  }
331
- const label = (x, y, text, extra = {}) => ({
332
- kind: 'text',
333
- part: 'label',
334
- x,
335
- y,
336
- text,
337
- fill: CHART_COLORS.label,
338
- ...extra,
339
- });
578
+ const label = (x, y, text, extra = {}) => ({ kind: 'text', part: 'label', x, y, text, fill: CHART_COLORS.label, ...extra });
340
579
  /** A dot with its native tooltip. */
341
- const point = (cx, cy, r, fill, title) => ({
342
- kind: 'circle',
343
- part: 'point',
344
- cx,
345
- cy,
346
- r,
347
- fill,
348
- title,
349
- });
580
+ const point = (cx, cy, r, fill, title, item) => ({ kind: 'circle', part: 'point', cx, cy, r, fill, title, item });
350
581
 
351
582
  /*
352
583
  * Copyright (c) 2026 SkandaDX
353
584
  * This file is part of the SkandaDX organization.
354
585
  * Licensed under the MIT License. See LICENSE in the repository root.
355
586
  */
356
- function layoutBarChart(input) {
357
- const yExtent = input.stacked ? stackedExtent(input) : extent(values(input), true);
358
- const { frame, x, y, categories } = buildFrame(input, yExtent);
359
- const bars = [];
360
- // Running stack tops per category, positives up and negatives down.
361
- const up = new Array(categories.length).fill(0);
362
- const down = new Array(categories.length).fill(0);
363
- const groupWidth = input.stacked ? x.bandwidth : x.bandwidth / Math.max(1, input.series.length);
364
- input.series.forEach((s, si) => {
365
- const color = seriesColor(si, s.color);
366
- categories.forEach((category, ci) => {
367
- const value = s.data[ci];
368
- if (value === undefined)
369
- return;
370
- let from = 0;
371
- let to = value;
372
- if (input.stacked) {
373
- const stack = value >= 0 ? up : down;
374
- from = stack[ci];
375
- to = from + value;
376
- stack[ci] = to;
587
+ /**
588
+ * Lines and bands in data coordinates, drawn behind the marks: targets, SLAs, budgets,
589
+ * forecast periods, healthy ranges. Each carries its label at the plot edge.
590
+ */
591
+ function referenceShapes(refs, s) {
592
+ const shapes = [];
593
+ const { plot } = s;
594
+ const right = plot.x + plot.width;
595
+ const bottom = plot.y + plot.height;
596
+ const clampX = (v) => Math.max(plot.x, Math.min(right, v));
597
+ const clampY = (v) => Math.max(plot.y, Math.min(bottom, v));
598
+ for (const r of refs ?? []) {
599
+ const color = variantColor(r.variant);
600
+ const meta = { part: 'reference-label', fill: color };
601
+ // Under horizontal orientation the value axis is x, so y references become vertical.
602
+ const yAsX = s.horizontal === true;
603
+ if ('y' in r) {
604
+ const p = px(s.y(r.y, r.axis ?? 'left'));
605
+ if (yAsX) {
606
+ const x = clampX(p);
607
+ shapes.push(...vline(x, plot.y, bottom, color, r.label, meta));
377
608
  }
378
- const y0 = y(from);
379
- const y1 = y(to);
380
- bars.push({
381
- kind: 'rect',
382
- part: 'bar',
383
- x: px(x.start(ci) + (input.stacked ? 0 : si * groupWidth)),
384
- y: px(Math.min(y0, y1)),
385
- width: px(groupWidth),
386
- height: px(Math.abs(y1 - y0)),
387
- fill: color,
388
- title: markTitle(s.name, category, value),
389
- });
390
- });
391
- });
392
- return { ...frame, shapes: [...frameShapes(frame), ...bars, ...frame.shapes] };
393
- }
394
- function stackedExtent(input) {
395
- let min = 0;
396
- let max = 0;
397
- const count = Math.max(0, ...input.series.map((s) => s.data.length));
398
- for (let ci = 0; ci < count; ci++) {
399
- let up = 0;
400
- let down = 0;
401
- for (const s of input.series) {
402
- const v = s.data[ci] ?? 0;
403
- if (v >= 0)
404
- up += v;
609
+ else {
610
+ const y = clampY(p);
611
+ shapes.push(...hline(plot.x, right, y, color, r.label, meta));
612
+ }
613
+ }
614
+ else if ('x' in r) {
615
+ const p = s.x(r.x);
616
+ if (p === undefined)
617
+ continue;
618
+ if (yAsX)
619
+ shapes.push(...hline(plot.x, right, clampY(px(p)), color, r.label, meta));
620
+ else
621
+ shapes.push(...vline(clampX(px(p)), plot.y, bottom, color, r.label, meta));
622
+ }
623
+ else if ('y1' in r) {
624
+ const a = px(s.y(r.y1, r.axis ?? 'left'));
625
+ const b = px(s.y(r.y2, r.axis ?? 'left'));
626
+ if (yAsX)
627
+ shapes.push(...vband(clampX(Math.min(a, b)), clampX(Math.max(a, b)), plot, color, r.label));
405
628
  else
406
- down += v;
629
+ shapes.push(...hband(clampY(Math.min(a, b)), clampY(Math.max(a, b)), plot, color, r.label));
630
+ }
631
+ else {
632
+ const a = s.x(r.x1);
633
+ const b = s.x(r.x2);
634
+ if (a === undefined || b === undefined)
635
+ continue;
636
+ const lo = px(Math.min(a, b));
637
+ const hi = px(Math.max(a, b));
638
+ if (yAsX)
639
+ shapes.push(...hband(clampY(lo), clampY(hi), plot, color, r.label));
640
+ else
641
+ shapes.push(...vband(clampX(lo), clampX(hi), plot, color, r.label));
407
642
  }
408
- min = Math.min(min, down);
409
- max = Math.max(max, up);
410
643
  }
411
- return [min, max];
644
+ return shapes;
645
+ }
646
+ function hline(x1, x2, y, stroke, text, extra) {
647
+ const line = {
648
+ kind: 'line',
649
+ part: 'reference',
650
+ x1,
651
+ x2,
652
+ y1: y,
653
+ y2: y,
654
+ stroke,
655
+ dash: '6 4',
656
+ strokeWidth: 1.5,
657
+ title: text,
658
+ };
659
+ return text ? [line, label(x2 - 4, y - 5, text, { ...extra, anchor: 'end' })] : [line];
660
+ }
661
+ function vline(x, y1, y2, stroke, text, extra) {
662
+ const line = {
663
+ kind: 'line',
664
+ part: 'reference',
665
+ x1: x,
666
+ x2: x,
667
+ y1,
668
+ y2,
669
+ stroke,
670
+ dash: '6 4',
671
+ strokeWidth: 1.5,
672
+ title: text,
673
+ };
674
+ return text ? [line, label(x + 4, y1 + 10, text, { ...extra, anchor: 'start' })] : [line];
675
+ }
676
+ function hband(y1, y2, plot, fill, text) {
677
+ const band = {
678
+ kind: 'rect',
679
+ part: 'reference-area',
680
+ x: plot.x,
681
+ y: y1,
682
+ width: plot.width,
683
+ height: px(Math.max(0, y2 - y1)),
684
+ fill,
685
+ opacity: 0.12,
686
+ title: text,
687
+ };
688
+ return text
689
+ ? [
690
+ band,
691
+ label(plot.x + plot.width - 4, y1 + 12, text, {
692
+ part: 'reference-label',
693
+ fill,
694
+ anchor: 'end',
695
+ }),
696
+ ]
697
+ : [band];
698
+ }
699
+ function vband(x1, x2, plot, fill, text) {
700
+ const band = {
701
+ kind: 'rect',
702
+ part: 'reference-area',
703
+ x: x1,
704
+ y: plot.y,
705
+ width: px(Math.max(0, x2 - x1)),
706
+ height: plot.height,
707
+ fill,
708
+ opacity: 0.12,
709
+ title: text,
710
+ };
711
+ return text
712
+ ? [band, label(x1 + 4, plot.y + 12, text, { part: 'reference-label', fill, anchor: 'start' })]
713
+ : [band];
412
714
  }
413
715
 
414
716
  /*
@@ -416,52 +718,546 @@ function stackedExtent(input) {
416
718
  * This file is part of the SkandaDX organization.
417
719
  * Licensed under the MIT License. See LICENSE in the repository root.
418
720
  */
419
- /** A stroke per series and a dot per value; `close` turns a stroke into a filled area. */
420
- function polylines(input, { frame, x, y, categories }, area) {
421
- const points = [];
422
- const lines = [];
721
+ /** Markers are drawn up to this many visible points per series. */
722
+ const MARKER_LIMIT = 100;
723
+ /** Per-point titles (and so per-point tooltips) are emitted up to this many visible points per series. */
724
+ const TITLE_LIMIT = 2000;
725
+ const BRUSH_HEIGHT$1 = 32;
726
+ const BRUSH_GAP$1 = 28;
727
+ /** Bars, lines, areas and range bands that share one category axis and up to two value axes. */
728
+ function layoutCartesian(input, defaultType) {
729
+ const format = makeFormatter(input.format);
730
+ const { series: all, categories: cats } = transform(input, defaultType, format);
731
+ const count = Math.max(cats.length, ...all.map((s) => Math.max(s.data.length, s.low.length)));
732
+ const empty = all.every((s) => finite(s.data).length === 0 && finite(s.high).length === 0);
733
+ const state = stateCanvas(input, empty);
734
+ if (state)
735
+ return state;
736
+ // A time or linear axis places numeric/date categories proportionally instead of in even slots.
737
+ const xType = input.xAxis?.type ?? 'category';
738
+ const continuous = xType !== 'category' && cats.length > 0 && cats.every((c) => typeof c !== 'string');
739
+ const xValues = continuous ? cats.map(categoryValue) : [];
740
+ const [xMin, xMax] = minMax(xValues);
741
+ const xFormat = input.xAxis?.format ??
742
+ (xType === 'time'
743
+ ? { style: xMax - xMin < 2 * 86_400_000 ? 'time' : 'date' }
744
+ : undefined);
745
+ const labels = Array.from({ length: count }, (_, i) => {
746
+ const c = cats[i];
747
+ return c === undefined ? String(i + 1) : categoryLabel(c, xFormat);
748
+ });
749
+ const zoom = clampZoom$1(input.zoom, count);
750
+ const visible = Array.from({ length: zoom.end - zoom.start }, (_, i) => zoom.start + i);
751
+ const horizontal = input.orientation === 'horizontal';
752
+ const shown = all.map((s) => ({ ...s, hidden: s.hidden || (horizontal && s.kind !== 'bar') }));
753
+ const drawn = shown.filter((s) => !s.hidden);
754
+ const percent = input.stacked === 'percent';
755
+ // Stack offsets: per group and category, the running top for positives and bottom for negatives.
756
+ const stackFrom = new Map();
757
+ const stackTo = new Map();
758
+ const groups = new Map();
759
+ for (const s of drawn)
760
+ if (s.stack !== undefined && s.kind !== 'range')
761
+ groups.set(s.stack, [...(groups.get(s.stack) ?? []), s]);
762
+ for (const members of groups.values()) {
763
+ const up = new Array(count).fill(0);
764
+ const down = new Array(count).fill(0);
765
+ const totals = new Array(count).fill(0);
766
+ if (percent)
767
+ for (const s of members)
768
+ s.data.forEach((v, ci) => {
769
+ if (isFinite(v))
770
+ totals[ci] = totals[ci] + Math.abs(v);
771
+ });
772
+ for (const s of members) {
773
+ const from = [];
774
+ const to = [];
775
+ for (let ci = 0; ci < count; ci++) {
776
+ const raw = s.data[ci];
777
+ if (!isFinite(raw)) {
778
+ from.push(null);
779
+ to.push(null);
780
+ continue;
781
+ }
782
+ const t = totals[ci];
783
+ const v = percent ? (t > 0 ? raw / t : 0) : raw;
784
+ const stack = v >= 0 ? up : down;
785
+ from.push(stack[ci]);
786
+ stack[ci] = stack[ci] + v;
787
+ to.push(stack[ci]);
788
+ }
789
+ stackFrom.set(s, from);
790
+ stackTo.set(s, to);
791
+ }
792
+ }
793
+ const valuesOf = (s) => s.kind === 'range'
794
+ ? { from: s.low, to: s.high }
795
+ : { from: stackFrom.get(s) ?? new Array(count).fill(0), to: stackTo.get(s) ?? s.data };
796
+ // Value extents per axis over the visible window; bars and areas grow the domain to include zero.
797
+ const extentOf = (side) => {
798
+ const values = [];
799
+ let zero = false;
800
+ for (const s of drawn) {
801
+ if (s.axis !== side)
802
+ continue;
803
+ const { from, to } = valuesOf(s);
804
+ for (const i of visible) {
805
+ const a = to[i];
806
+ if (isFinite(a))
807
+ values.push(a);
808
+ if (s.kind === 'range' || s.stack !== undefined) {
809
+ const b = from[i];
810
+ if (isFinite(b))
811
+ values.push(b);
812
+ }
813
+ }
814
+ if (s.kind === 'bar' || s.kind === 'area')
815
+ zero = true;
816
+ }
817
+ const [min, max] = minMax(values);
818
+ return zero ? [Math.min(0, min), Math.max(0, max)] : [min, max];
819
+ };
820
+ const rightUsed = input.rightAxis !== undefined || drawn.some((s) => s.axis === 'right');
821
+ const { width, height } = size(input);
822
+ const brushRoom = input.brush ? BRUSH_HEIGHT$1 + BRUSH_GAP$1 : 0;
823
+ const widestCategory = visible.reduce((w, i) => Math.max(w, labels[i].length), 0) * CHAR;
824
+ const margin = {
825
+ top: MARGIN$1.top + legendTop(input) + (input.yAxis?.label || input.rightAxis?.label ? 12 : 0),
826
+ right: rightUsed ? MARGIN$1.left : MARGIN$1.right,
827
+ bottom: MARGIN$1.bottom + brushRoom + (input.xAxis?.label ? 14 : 0),
828
+ left: horizontal ? Math.max(MARGIN$1.left, widestCategory + 16) : MARGIN$1.left,
829
+ };
830
+ const plot = plotBox(width, height, margin);
831
+ const valueRange = horizontal
832
+ ? [plot.x, plot.x + plot.width]
833
+ : [plot.y + plot.height, plot.y];
834
+ const bandRange = horizontal
835
+ ? [plot.y, plot.y + plot.height]
836
+ : [plot.x, plot.x + plot.width];
837
+ const percentFormat = percent ? { style: 'percent' } : undefined;
838
+ const left = axis(extentOf('left'), valueRange, { format: percentFormat, ...input.yAxis }, input.format);
839
+ const right = rightUsed
840
+ ? axis(extentOf('right'), valueRange, { format: percentFormat, ...input.rightAxis }, input.format)
841
+ : undefined;
842
+ const value = (v, side) => (side === 'right' && right ? right : left).scale(v);
843
+ const band = bandScale(visible.length, bandRange);
844
+ // Continuous: the visible values map linearly onto the band range, padded by half the smallest
845
+ // gap so edge bars stay inside; the bar slot is that smallest gap.
846
+ const visibleX = visible.map((i) => xValues[i]);
847
+ const sortedX = [...visibleX].sort((a, b) => a - b);
848
+ let minGap = Infinity;
849
+ for (let k = 1; k < sortedX.length; k++)
850
+ minGap = Math.min(minGap, sortedX[k] - sortedX[k - 1]);
851
+ const [vMin, vMax] = minMax(visibleX);
852
+ const pad = minGap === Infinity ? Math.max(1, Math.abs(vMax) * 0.05 || 1) : minGap / 2;
853
+ const xScale = linearScale([vMin - pad, vMax + pad], bandRange);
854
+ const center = (vi) => px(continuous ? xScale(visibleX[vi]) : band.center(vi));
855
+ const slot = continuous
856
+ ? Math.abs(xScale(vMin + (minGap === Infinity ? pad : minGap)) - xScale(vMin)) * 0.8
857
+ : band.bandwidth;
858
+ const slotStart = (vi) => (continuous ? center(vi) - slot / 2 : band.start(vi));
859
+ const categoryTicks = continuous
860
+ ? (xType === 'time' ? timeTicks(vMin - pad, vMax + pad) : niceTicks(vMin - pad, vMax + pad))
861
+ .filter((v) => v >= vMin - pad && v <= vMax + pad)
862
+ .map((v) => ({ pos: px(xScale(v)), label: categoryLabel(v, xFormat) }))
863
+ : thinTicks(visible.map((i, vi) => ({ pos: center(vi), label: labels[i] })), horizontal ? Infinity : plot.width);
864
+ const frame = {
865
+ ...canvas(input, width, height, all.map((s) => ({ name: s.name, color: s.color }))),
866
+ plot,
867
+ xTicks: categoryTicks,
868
+ yTicks: left.ticks,
869
+ baseline: left.zero,
870
+ };
871
+ const shapes = frameShapes(frame, {
872
+ horizontal,
873
+ rightTicks: right?.ticks,
874
+ xLabel: input.xAxis?.label,
875
+ yLabel: input.yAxis?.label,
876
+ rightLabel: input.rightAxis?.label,
877
+ });
878
+ // References sit behind the marks. `x` references name a category (label, value or index).
879
+ const indexOfCategory = (c) => {
880
+ if (typeof c === 'number' && cats.every((x) => typeof x === 'string'))
881
+ return c;
882
+ const target = typeof c === 'string' ? c : categoryValue(c);
883
+ for (let i = 0; i < count; i++) {
884
+ const cat = cats[i];
885
+ if (cat === undefined)
886
+ continue;
887
+ if (typeof target === 'string'
888
+ ? cat === target || labels[i] === target
889
+ : categoryValue(cat) === target)
890
+ return i;
891
+ }
892
+ return undefined;
893
+ };
894
+ shapes.push(...referenceShapes(input.references, {
895
+ plot,
896
+ horizontal,
897
+ x: (c) => {
898
+ if (continuous && typeof c !== 'string')
899
+ return xScale(categoryValue(c));
900
+ const i = indexOfCategory(c);
901
+ const vi = i === undefined ? -1 : visible.indexOf(i);
902
+ return vi < 0 ? undefined : band.center(vi);
903
+ },
904
+ y: value,
905
+ }));
906
+ // One slot per plain bar series and one per bar stack group, side by side inside the band.
907
+ const slots = [];
908
+ const slotOf = new Map();
909
+ for (const s of drawn) {
910
+ if (s.kind !== 'bar')
911
+ continue;
912
+ const key = s.stack ?? s;
913
+ let at = slots.indexOf(key);
914
+ if (at < 0)
915
+ at = slots.push(key) - 1;
916
+ slotOf.set(s, at);
917
+ }
918
+ const groupWidth = slot / Math.max(1, slots.length);
423
919
  const areas = [];
424
- input.series.forEach((s, si) => {
425
- const color = seriesColor(si, s.color);
426
- const coords = s.data.map((value, ci) => {
427
- const p = { x: px(x.center(ci)), y: px(y(value)) };
428
- points.push(point(p.x, p.y, 3, color, markTitle(s.name, categories[ci], value)));
429
- return p;
430
- });
431
- const d = pathThrough(coords, input.curve);
432
- lines.push({
433
- kind: 'path',
434
- part: 'line',
435
- d,
436
- stroke: color,
437
- strokeWidth: 2,
438
- strokeLinecap: 'round',
439
- strokeLinejoin: 'round',
920
+ const bars = [];
921
+ const lines = [];
922
+ const points = [];
923
+ const texts = [];
924
+ const fmt = (s) => (s.axis === 'right' && right ? right : left).format;
925
+ const itemOf = (s, i, v, title) => ({
926
+ label: title,
927
+ series: s.name,
928
+ category: labels[i],
929
+ index: i,
930
+ value: v,
931
+ });
932
+ for (const s of drawn) {
933
+ const { from, to } = valuesOf(s);
934
+ const f = fmt(s);
935
+ const titled = visible.length <= TITLE_LIMIT;
936
+ if (s.kind === 'bar') {
937
+ visible.forEach((i, vi) => {
938
+ const raw = s.data[i];
939
+ const a = from[i];
940
+ const b = to[i];
941
+ if (!isFinite(raw) || !isFinite(a) || !isFinite(b))
942
+ return;
943
+ const v0 = value(a, s.axis);
944
+ const v1 = value(b, s.axis);
945
+ const b0 = slotStart(vi) + slotOf.get(s) * groupWidth;
946
+ const title = markTitle(s.name, labels[i], percent ? b - a : raw, percent ? f : f);
947
+ const rect = horizontal
948
+ ? {
949
+ kind: 'rect',
950
+ part: 'bar',
951
+ x: px(Math.min(v0, v1)),
952
+ y: px(b0),
953
+ width: px(Math.abs(v1 - v0)),
954
+ height: px(groupWidth),
955
+ fill: s.color,
956
+ rx: input.radius,
957
+ title: titled ? title : undefined,
958
+ item: itemOf(s, i, raw, title),
959
+ }
960
+ : {
961
+ kind: 'rect',
962
+ part: 'bar',
963
+ x: px(b0),
964
+ y: px(Math.min(v0, v1)),
965
+ width: px(groupWidth),
966
+ height: px(Math.abs(v1 - v0)),
967
+ fill: s.color,
968
+ rx: input.radius,
969
+ title: titled ? title : undefined,
970
+ item: itemOf(s, i, raw, title),
971
+ };
972
+ bars.push(rect);
973
+ if (input.labels) {
974
+ const text = f(percent ? b - a : raw);
975
+ texts.push(horizontal
976
+ ? label(px(Math.max(v0, v1) + 4), px(b0 + groupWidth / 2), text, {
977
+ baseline: 'middle',
978
+ })
979
+ : label(px(b0 + groupWidth / 2), px(Math.min(v0, v1) - 4), text, {
980
+ anchor: 'middle',
981
+ }));
982
+ }
983
+ });
984
+ continue;
985
+ }
986
+ // Lines, areas and ranges: polylines broken at gaps unless `connectNulls`.
987
+ const segments = [];
988
+ let current = [];
989
+ visible.forEach((i, vi) => {
990
+ const a = from[i];
991
+ const b = to[i];
992
+ if (!isFinite(b) || !isFinite(a)) {
993
+ if (!input.connectNulls && current.length) {
994
+ segments.push(current);
995
+ current = [];
996
+ }
997
+ return;
998
+ }
999
+ current.push({ x: center(vi), y: px(value(b, s.axis)), y0: px(value(a, s.axis)), i });
440
1000
  });
441
- if (area) {
442
- const first = coords[0];
443
- const last = coords[coords.length - 1];
444
- areas.push({
1001
+ if (current.length)
1002
+ segments.push(current);
1003
+ const showMarkers = s.markers ?? visible.length <= MARKER_LIMIT;
1004
+ for (const seg of segments) {
1005
+ const d = pathThrough(seg, s.curve);
1006
+ if (s.kind === 'range') {
1007
+ const lower = pathThrough([...seg].reverse().map((p) => ({ x: p.x, y: p.y0 })), s.curve);
1008
+ areas.push({
1009
+ kind: 'path',
1010
+ part: 'range',
1011
+ d: `${d}L${lower.slice(1)}Z`,
1012
+ fill: s.color,
1013
+ fillOpacity: 0.2,
1014
+ title: s.name,
1015
+ });
1016
+ continue;
1017
+ }
1018
+ if (s.kind === 'area') {
1019
+ const bottom = s.stack !== undefined
1020
+ ? pathThrough([...seg].reverse().map((p) => ({ x: p.x, y: p.y0 })), s.curve).slice(1)
1021
+ : `${seg[seg.length - 1]?.x} ${left.zero}L${seg[0]?.x} ${left.zero}`;
1022
+ areas.push({
1023
+ kind: 'path',
1024
+ part: 'area',
1025
+ d: `${d}L${bottom}Z`,
1026
+ fill: s.color,
1027
+ fillOpacity: 0.25,
1028
+ });
1029
+ }
1030
+ lines.push({
445
1031
  kind: 'path',
446
- part: 'area',
447
- d: d && first && last
448
- ? `${d}L${last.x} ${frame.baseline}L${first.x} ${frame.baseline}Z`
449
- : '',
450
- fill: color,
451
- fillOpacity: 0.25,
1032
+ part: 'line',
1033
+ d,
1034
+ stroke: s.color,
1035
+ strokeWidth: 2,
1036
+ strokeLinecap: 'round',
1037
+ strokeLinejoin: 'round',
1038
+ dash: s.dash,
452
1039
  });
453
1040
  }
454
- });
1041
+ if (s.kind !== 'range')
1042
+ for (const seg of segments)
1043
+ for (const p of seg) {
1044
+ if (!showMarkers && !titled && !input.labels)
1045
+ continue;
1046
+ const raw = s.data[p.i];
1047
+ const shownValue = percent ? to[p.i] - from[p.i] : raw;
1048
+ const title = markTitle(s.name, labels[p.i], shownValue, f);
1049
+ if (showMarkers || titled)
1050
+ points.push({
1051
+ ...point(p.x, p.y, showMarkers ? 3 : 0.01, s.color, titled ? title : undefined, itemOf(s, p.i, raw, title)),
1052
+ // Invisible anchors keep click and keyboard access when markers are off.
1053
+ part: showMarkers ? 'point' : 'anchor',
1054
+ });
1055
+ if (input.labels)
1056
+ texts.push(label(p.x, p.y - 8, f(shownValue), { anchor: 'middle' }));
1057
+ }
1058
+ }
1059
+ const hit = {
1060
+ plot,
1061
+ xs: horizontal ? [] : visible.map((_, vi) => center(vi)),
1062
+ rows: visible.map((i) => [
1063
+ labels[i],
1064
+ ...drawn.map((s) => {
1065
+ const f = fmt(s);
1066
+ if (s.kind === 'range') {
1067
+ const lo = s.low[i];
1068
+ const hi = s.high[i];
1069
+ return `${s.name}: ${isFinite(lo) && isFinite(hi) ? `${f(lo)} – ${f(hi)}` : '—'}`;
1070
+ }
1071
+ const v = s.data[i];
1072
+ return `${s.name}: ${isFinite(v) ? f(percent ? (stackTo.get(s)?.[i] ?? 0) - (stackFrom.get(s)?.[i] ?? 0) : v) : '—'}`;
1073
+ }),
1074
+ ]),
1075
+ count,
1076
+ zoom,
1077
+ crosshair: input.crosshair ?? false,
1078
+ };
1079
+ const brush = [];
1080
+ if (input.brush && !horizontal) {
1081
+ const box = {
1082
+ x: plot.x,
1083
+ y: plot.y + plot.height + BRUSH_GAP$1,
1084
+ width: plot.width,
1085
+ height: BRUSH_HEIGHT$1,
1086
+ };
1087
+ const first = drawn.find((s) => s.kind !== 'range');
1088
+ const window = {
1089
+ x: px(box.x + (zoom.start / Math.max(1, count)) * box.width),
1090
+ y: box.y,
1091
+ width: px(((zoom.end - zoom.start) / Math.max(1, count)) * box.width),
1092
+ height: box.height,
1093
+ };
1094
+ brush.push({
1095
+ kind: 'rect',
1096
+ part: 'brush',
1097
+ x: box.x,
1098
+ y: box.y,
1099
+ width: box.width,
1100
+ height: box.height,
1101
+ fill: 'var(--skdx-color-surface-sunken)',
1102
+ stroke: CHART_COLORS.grid,
1103
+ });
1104
+ if (first) {
1105
+ const values = finite(first.data);
1106
+ const [lo, hi] = minMax(values);
1107
+ const step = count > 1 ? box.width / (count - 1) : 0;
1108
+ const pts = first.data.flatMap((v, i) => isFinite(v)
1109
+ ? [
1110
+ {
1111
+ x: px(box.x + i * step),
1112
+ y: px(box.y +
1113
+ box.height -
1114
+ (hi === lo ? 0.5 : (v - lo) / (hi - lo)) * (box.height - 4) -
1115
+ 2),
1116
+ },
1117
+ ]
1118
+ : []);
1119
+ const d = pathThrough(pts);
1120
+ if (pts.length > 1)
1121
+ brush.push({
1122
+ kind: 'path',
1123
+ part: 'brush-series',
1124
+ d: `${d}L${pts[pts.length - 1]?.x} ${box.y + box.height}L${pts[0]?.x} ${box.y + box.height}Z`,
1125
+ fill: first.color,
1126
+ fillOpacity: 0.3,
1127
+ });
1128
+ }
1129
+ brush.push({
1130
+ kind: 'rect',
1131
+ part: 'brush-window',
1132
+ ...window,
1133
+ fill: 'var(--skdx-color-primary-500)',
1134
+ opacity: 0.15,
1135
+ stroke: CHART_COLORS.axis,
1136
+ }, {
1137
+ kind: 'rect',
1138
+ part: 'brush-handle',
1139
+ x: px(window.x - 3),
1140
+ y: box.y + 8,
1141
+ width: 6,
1142
+ height: box.height - 16,
1143
+ fill: CHART_COLORS.axis,
1144
+ rx: 2,
1145
+ }, {
1146
+ kind: 'rect',
1147
+ part: 'brush-handle',
1148
+ x: px(window.x + window.width - 3),
1149
+ y: box.y + 8,
1150
+ width: 6,
1151
+ height: box.height - 16,
1152
+ fill: CHART_COLORS.axis,
1153
+ rx: 2,
1154
+ });
1155
+ hit.brush = { box, window };
1156
+ }
455
1157
  return {
456
1158
  ...frame,
457
- shapes: [...frameShapes(frame), ...areas, ...lines, ...points, ...frame.shapes],
1159
+ shapes: [
1160
+ ...shapes,
1161
+ ...areas,
1162
+ ...bars,
1163
+ ...lines,
1164
+ ...points,
1165
+ ...texts,
1166
+ ...brush,
1167
+ ...frame.shapes,
1168
+ ],
1169
+ hit,
458
1170
  };
459
1171
  }
460
- function layoutLineChart(input) {
461
- return polylines(input, buildFrame(input, extent(values(input), false)), false);
1172
+ function clampZoom$1(zoom, count) {
1173
+ if (!zoom || count < 2)
1174
+ return { start: 0, end: count };
1175
+ const start = Math.max(0, Math.min(count - 1, Math.floor(zoom.start)));
1176
+ const end = Math.max(start + 1, Math.min(count, Math.ceil(zoom.end)));
1177
+ return end - start < 2 && count >= 2
1178
+ ? { start: Math.min(start, count - 2), end: Math.min(start, count - 2) + 2 }
1179
+ : { start, end };
462
1180
  }
463
- function layoutAreaChart(input) {
464
- return polylines(input, buildFrame(input, extent(values(input), true)), true);
1181
+ /** Resolves series roles and applies `transform`, `sort` and chart-level stacking. */
1182
+ function transform(input, fallback, format) {
1183
+ const defaultType = (s) => s.type ?? fallback;
1184
+ let categories = input.categories ?? [];
1185
+ let series = input.series.map((s, index) => {
1186
+ const kind = defaultType(s);
1187
+ const stacked = input.stacked ? true : undefined;
1188
+ const own = s.type === 'range' ? undefined : s.stack;
1189
+ const stack = own === false ? undefined : own === true || (own === undefined && stacked) ? `${kind}` : own;
1190
+ return {
1191
+ name: s.name,
1192
+ color: colorOf(input, index, s.color),
1193
+ axis: s.axis ?? 'left',
1194
+ hidden: isHidden(input, s.name),
1195
+ index,
1196
+ kind,
1197
+ data: s.type === 'range' ? [] : s.data,
1198
+ low: s.type === 'range' ? s.low : [],
1199
+ high: s.type === 'range' ? s.high : [],
1200
+ curve: s.curve ?? input.curve ?? 'linear',
1201
+ dash: s.type === 'range' ? undefined : s.dash,
1202
+ markers: s.type === 'range' ? undefined : s.markers,
1203
+ stack: kind === 'range' || kind === 'line' ? undefined : stack,
1204
+ };
1205
+ });
1206
+ if (input.transform === 'histogram') {
1207
+ const samples = series.flatMap((s) => finite(s.data));
1208
+ const [lo, hi] = input.binDomain ?? minMax(samples);
1209
+ const n = Math.max(1, samples.length);
1210
+ const bins = Math.max(1, input.bins ?? Math.ceil(Math.log2(n) + 1));
1211
+ const width = input.binWidth ?? (hi > lo ? (hi - lo) / bins : 1);
1212
+ const binCount = hi > lo ? Math.max(1, Math.ceil((hi - lo) / width - 1e-9)) : 1;
1213
+ categories = Array.from({ length: binCount }, (_, i) => `${format(lo + i * width)}–${format(lo + (i + 1) * width)}`);
1214
+ series = series.map((s) => {
1215
+ const counts = new Array(binCount).fill(0);
1216
+ const own = finite(s.data);
1217
+ for (const v of own) {
1218
+ if (v < lo || v > hi)
1219
+ continue;
1220
+ const b = Math.min(binCount - 1, Math.floor((v - lo) / width));
1221
+ counts[b] = counts[b] + 1;
1222
+ }
1223
+ let running = 0;
1224
+ const data = counts.map((c) => {
1225
+ if (input.binMode === 'density')
1226
+ return own.length ? c / (own.length * width) : 0;
1227
+ if (input.binMode === 'cumulative')
1228
+ return (running += c);
1229
+ return c;
1230
+ });
1231
+ return { ...s, data };
1232
+ });
1233
+ }
1234
+ else if (input.transform === 'cumulative') {
1235
+ series = series.map((s) => {
1236
+ let running = 0;
1237
+ return { ...s, data: s.data.map((v) => (isFinite(v) ? (running += v) : running)) };
1238
+ });
1239
+ }
1240
+ if (input.sort) {
1241
+ const first = series.find((s) => !s.hidden && s.kind !== 'range') ?? series[0];
1242
+ const n = Math.max(categories.length, ...series.map((s) => Math.max(s.data.length, s.low.length)));
1243
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => {
1244
+ const va = first?.data[a] ?? first?.high[a];
1245
+ const vb = first?.data[b] ?? first?.high[b];
1246
+ const x = isFinite(va) ? va : -Infinity;
1247
+ const y = isFinite(vb) ? vb : -Infinity;
1248
+ return input.sort === 'asc' ? x - y : y - x;
1249
+ });
1250
+ const pick = (arr) => (arr.length ? order.map((i) => arr[i] ?? null) : arr);
1251
+ const full = Array.from({ length: n }, (_, i) => categories[i] ?? String(i + 1));
1252
+ categories = order.map((i) => full[i]);
1253
+ series = series.map((s) => ({
1254
+ ...s,
1255
+ data: pick(s.data),
1256
+ low: pick(s.low),
1257
+ high: pick(s.high),
1258
+ }));
1259
+ }
1260
+ return { series, categories };
465
1261
  }
466
1262
 
467
1263
  /*
@@ -469,20 +1265,126 @@ function layoutAreaChart(input) {
469
1265
  * This file is part of the SkandaDX organization.
470
1266
  * Licensed under the MIT License. See LICENSE in the repository root.
471
1267
  */
1268
+ /** Grouped, stacked, horizontal or histogram bars; series may also be lines, areas or ranges. */
1269
+ const layoutBarChart = (input) => layoutCartesian(input, 'bar');
1270
+
1271
+ /*
1272
+ * Copyright (c) 2026 SkandaDX
1273
+ * This file is part of the SkandaDX organization.
1274
+ * Licensed under the MIT License. See LICENSE in the repository root.
1275
+ */
1276
+ /** One polyline per series with a dot per value; series may also be bars, areas or ranges. */
1277
+ const layoutLineChart = (input) => layoutCartesian(input, 'line');
1278
+ /** Filled (optionally stacked) areas down to zero; series may also be bars, lines or ranges. */
1279
+ const layoutAreaChart = (input) => layoutCartesian(input, 'area');
1280
+
1281
+ /*
1282
+ * Copyright (c) 2026 SkandaDX
1283
+ * This file is part of the SkandaDX organization.
1284
+ * Licensed under the MIT License. See LICENSE in the repository root.
1285
+ */
1286
+ const isPair = (p) => isFinite(p[0]) && isFinite(p[1]);
472
1287
  function layoutScatterChart(input) {
1288
+ const shown = input.series.map((s, i) => ({
1289
+ ...s,
1290
+ index: i,
1291
+ color: colorOf(input, i, s.color),
1292
+ data: isHidden(input, s.name) ? [] : s.data.filter(isPair),
1293
+ }));
1294
+ const pairs = shown.flatMap((s) => s.data);
1295
+ const state = stateCanvas(input, pairs.length === 0);
1296
+ if (state)
1297
+ return state;
473
1298
  const { width, height } = size(input);
474
- const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
475
- const pairs = input.series.flatMap((s) => s.data);
476
- const x = axis(extent(pairs.map((p) => p[0]), false), [plot.x, plot.x + plot.width]);
477
- const y = axis(extent(pairs.map((p) => p[1]), false), [plot.y + plot.height, plot.y]);
478
- const legend = input.series.map((s, i) => ({ name: s.name, color: seriesColor(i, s.color) }));
479
- const c = canvas(input, width, height, legend);
480
- const points = input.series.flatMap((s, si) => {
481
- const color = seriesColor(si, s.color);
482
- return s.data.map(([vx, vy]) => point(px(x.scale(vx)), px(y.scale(vy)), 3, color, `${s.name}: ${formatValue(vx)}, ${formatValue(vy)}`));
1299
+ const plot = plotBox(width, height, {
1300
+ ...MARGIN$1,
1301
+ top: MARGIN$1.top + legendTop(input) + (input.yAxis?.label ? 12 : 0),
1302
+ bottom: MARGIN$1.bottom + (input.xAxis?.label ? 14 : 0),
483
1303
  });
1304
+ const x = axis(extent(pairs.map((p) => p[0]), false), [plot.x, plot.x + plot.width], input.xAxis ?? {}, input.format);
1305
+ const y = axis(extent(pairs.map((p) => p[1]), false), [plot.y + plot.height, plot.y], input.yAxis ?? {}, input.format);
1306
+ const [sMin, sMax] = minMax(pairs.map((p) => p[2]));
1307
+ const [rMin, rMax] = input.sizeRange ?? [3, 18];
1308
+ // Bubble area, not radius, follows the value, so a doubled value reads as doubled.
1309
+ const radius = (s) => !isFinite(s)
1310
+ ? 3
1311
+ : sMax === sMin
1312
+ ? (rMin + rMax) / 2
1313
+ : rMin + Math.sqrt((s - sMin) / (sMax - sMin)) * (rMax - rMin);
1314
+ const legend = input.series.map((s, i) => ({ name: s.name, color: colorOf(input, i, s.color) }));
1315
+ const c = canvas(input, width, height, legend);
1316
+ const marks = [];
1317
+ const trends = [];
1318
+ for (const s of shown) {
1319
+ const bubbles = s.data.some((p) => p.length > 2);
1320
+ for (const [vx, vy, vs] of s.data) {
1321
+ const title = `${s.name}: ${x.format(vx)}, ${y.format(vy)}${isFinite(vs) ? ` (${y.format(vs)})` : ''}`;
1322
+ const r = bubbles ? radius(vs) : 3;
1323
+ marks.push({
1324
+ ...point(px(x.scale(vx)), px(y.scale(vy)), px(r), s.color, title, {
1325
+ label: title,
1326
+ series: s.name,
1327
+ value: vy,
1328
+ }),
1329
+ part: bubbles ? 'bubble' : 'point',
1330
+ });
1331
+ }
1332
+ if (input.trendline && s.data.length >= 2) {
1333
+ const line = leastSquares(s.data.map(([a, b]) => [a, b]));
1334
+ if (line) {
1335
+ const [x0, x1] = x.domain;
1336
+ trends.push({
1337
+ kind: 'line',
1338
+ part: 'trendline',
1339
+ x1: px(x.scale(x0)),
1340
+ y1: px(y.scale(line.slope * x0 + line.intercept)),
1341
+ x2: px(x.scale(x1)),
1342
+ y2: px(y.scale(line.slope * x1 + line.intercept)),
1343
+ stroke: s.color,
1344
+ dash: '5 4',
1345
+ strokeWidth: 1.5,
1346
+ title: `${s.name} trend: r² ${line.r2.toFixed(2)}`,
1347
+ });
1348
+ }
1349
+ }
1350
+ }
484
1351
  const frame = { ...c, plot, xTicks: x.ticks, yTicks: y.ticks, baseline: y.zero };
485
- return { ...c, shapes: [...frameShapes(frame), ...points, ...c.shapes] };
1352
+ const references = referenceShapes(input.references, {
1353
+ plot,
1354
+ x: (v) => x.scale(categoryValue(v)),
1355
+ y: (v) => y.scale(v),
1356
+ });
1357
+ return {
1358
+ ...c,
1359
+ shapes: [
1360
+ ...frameShapes(frame, { xLabel: input.xAxis?.label, yLabel: input.yAxis?.label }),
1361
+ ...references,
1362
+ ...(marks.some((m) => m.part === 'bubble')
1363
+ ? marks.map((m) => (m.kind === 'circle' ? { ...m, opacity: 0.7 } : m))
1364
+ : marks),
1365
+ ...trends,
1366
+ ...c.shapes,
1367
+ ],
1368
+ };
1369
+ }
1370
+ /** Ordinary least squares over `[x, y]` pairs; `undefined` when x is constant. */
1371
+ function leastSquares(pairs) {
1372
+ const n = pairs.length;
1373
+ const mx = pairs.reduce((s, p) => s + p[0], 0) / n;
1374
+ const my = pairs.reduce((s, p) => s + p[1], 0) / n;
1375
+ let sxx = 0;
1376
+ let sxy = 0;
1377
+ let syy = 0;
1378
+ for (const [a, b] of pairs) {
1379
+ sxx += (a - mx) ** 2;
1380
+ sxy += (a - mx) * (b - my);
1381
+ syy += (b - my) ** 2;
1382
+ }
1383
+ if (sxx === 0)
1384
+ return undefined;
1385
+ const slope = sxy / sxx;
1386
+ const r2 = syy === 0 ? 1 : (sxy * sxy) / (sxx * syy);
1387
+ return { slope, intercept: my - slope * mx, r2 };
486
1388
  }
487
1389
 
488
1390
  /*
@@ -506,38 +1408,68 @@ function arc(cx, cy, r, r0, a0, a1) {
506
1408
  : `${outer}L${px(cx)} ${px(cy)}Z`;
507
1409
  }
508
1410
  function layoutPieChart(input) {
1411
+ const state = stateCanvas(input, !input.data.some((d) => isFinite(d.value) && d.value > 0), 300, 300);
1412
+ if (state)
1413
+ return state;
1414
+ const format = makeFormatter(input.format);
509
1415
  const { width, height } = size(input, 300, 300);
510
1416
  const top = legendTop(input);
1417
+ const start = ((input.startAngle ?? 0) * Math.PI) / 180;
1418
+ const end = ((input.endAngle ?? (input.startAngle ?? 0) + 360) * Math.PI) / 180;
1419
+ const sweep = Math.max(0, Math.min(TAU$2, end - start));
1420
+ // A half circle only needs half the height: centre it on the flat edge.
1421
+ const half = sweep <= Math.PI + 1e-9 && Math.abs(Math.sin(start)) > 0.99;
511
1422
  const cx = width / 2;
512
- const cy = top + (height - top) / 2;
513
- const r = Math.max(0, Math.min(width, height - top) / 2 - 8);
1423
+ const r = half
1424
+ ? Math.max(0, Math.min(width / 2, height - top) - 8)
1425
+ : Math.max(0, Math.min(width, height - top) / 2 - 8);
1426
+ const cy = half
1427
+ ? start < 0 || start > Math.PI
1428
+ ? height - 8
1429
+ : top + 8
1430
+ : top + (height - top) / 2;
514
1431
  const r0 = r * Math.min(0.95, Math.max(0, input.innerRadius ?? 0));
515
- const total = input.data.reduce((sum, d) => sum + Math.max(0, d.value), 0);
516
- let angle = 0;
1432
+ const shown = input.data.map((d) => ({
1433
+ ...d,
1434
+ value: isFinite(d.value) && !isHidden(input, d.name) ? Math.max(0, d.value) : 0,
1435
+ }));
1436
+ const total = shown.reduce((sum, d) => sum + d.value, 0);
1437
+ let angle = start;
517
1438
  const labels = [];
518
- const slices = input.data.map((d, i) => {
519
- const percent = total > 0 ? Math.max(0, d.value) / total : 0;
1439
+ const slices = shown.map((d, i) => {
1440
+ const percent = total > 0 ? d.value / total : 0;
520
1441
  const a0 = angle;
521
- angle += percent * TAU$2;
1442
+ angle += percent * sweep;
1443
+ const share = `${Math.round(percent * 100)}%`;
522
1444
  if (input.labels && percent > 0.03) {
523
1445
  const mid = (a0 + angle) / 2;
524
1446
  const lr = (r + r0) / 2;
525
- labels.push(label(px(cx + lr * Math.sin(mid)), px(cy - lr * Math.cos(mid)), `${d.name} ${Math.round(percent * 100)}%`, {
1447
+ labels.push(label(px(cx + lr * Math.sin(mid)), px(cy - lr * Math.cos(mid)), `${d.name} ${share}`, {
526
1448
  part: 'slice-label',
527
1449
  anchor: 'middle',
528
1450
  baseline: 'middle',
529
1451
  fill: 'var(--skdx-color-text-on-accent)',
530
1452
  }));
531
1453
  }
1454
+ const title = `${d.name}: ${format(d.value)} (${share})`;
532
1455
  return {
533
1456
  kind: 'path',
534
1457
  part: 'slice',
535
1458
  d: percent > 0 ? arc(cx, cy, r, r0, a0, angle) : '',
536
- fill: seriesColor(i, d.color),
537
- title: `${d.name}: ${formatValue(d.value)} (${Math.round(percent * 100)}%)`,
1459
+ fill: colorOf(input, i, d.color),
1460
+ title,
1461
+ item: percent > 0 ? { label: title, name: d.name, index: i, value: d.value } : undefined,
538
1462
  };
539
1463
  });
540
- const c = canvas(input, width, height, input.data.map((d, i) => ({ name: d.name, color: seriesColor(i, d.color) })));
1464
+ if (input.centerLabel)
1465
+ labels.push(label(px(cx), px(half ? cy - 12 : cy), input.centerLabel, {
1466
+ part: 'center-label',
1467
+ anchor: 'middle',
1468
+ baseline: 'middle',
1469
+ fill: 'var(--skdx-color-text-default)',
1470
+ weight: 'bold',
1471
+ }));
1472
+ const c = canvas(input, width, height, input.data.map((d, i) => ({ name: d.name, color: colorOf(input, i, d.color) })));
541
1473
  return { ...c, shapes: [...slices, ...c.shapes, ...labels] };
542
1474
  }
543
1475
 
@@ -547,34 +1479,87 @@ function layoutPieChart(input) {
547
1479
  * Licensed under the MIT License. See LICENSE in the repository root.
548
1480
  */
549
1481
  const PAD$3 = 2;
1482
+ /** A small axis-less trend: an area (default), a bare line, or bars; optional extremes and last value. */
550
1483
  function layoutSparkline(input) {
1484
+ const values = finite(input.data);
1485
+ const state = stateCanvas(input, values.length === 0, 120, 32);
1486
+ if (state)
1487
+ return state;
1488
+ const format = makeFormatter(input.format);
551
1489
  const { width, height } = size(input, 120, 32);
552
1490
  const n = input.data.length;
553
- const y = linearScale(extent(input.data, false), [height - PAD$3, PAD$3]);
554
- const x = (i) => (n < 2 ? width / 2 : PAD$3 + (i / (n - 1)) * (width - 2 * PAD$3));
555
- const coords = input.data.map((v, i) => ({ x: px(x(i)), y: px(y(v)) }));
556
- const line = pathThrough(coords, input.curve);
557
- const last = coords[coords.length - 1];
558
- const first = coords[0];
559
- const color = seriesColor(0, input.color);
560
- return {
561
- width,
562
- height,
563
- tooltip: input.tooltip ?? true,
564
- shapes: [
565
- {
1491
+ const first = values[0];
1492
+ const last = values[values.length - 1];
1493
+ const lastText = input.lastValue ? format(last) : '';
1494
+ const right = width - PAD$3 - (lastText ? lastText.length * CHAR + 6 : 0);
1495
+ const variant = input.variant ?? 'area';
1496
+ const y = linearScale(extent(values, variant === 'bar'), [height - PAD$3, PAD$3]);
1497
+ const x = (i) => (n < 2 ? (PAD$3 + right) / 2 : PAD$3 + (i / (n - 1)) * (right - PAD$3));
1498
+ const color = input.trend
1499
+ ? last >= first
1500
+ ? CHART_COLORS.positive
1501
+ : CHART_COLORS.negative
1502
+ : colorOf(input, 0, input.color);
1503
+ const shapes = [];
1504
+ const coords = input.data.flatMap((v, i) => isFinite(v) ? [{ x: px(x(i)), y: px(y(v)), v, i }] : []);
1505
+ const tail = coords[coords.length - 1];
1506
+ if (variant === 'bar') {
1507
+ const slot = n > 0 ? (right - PAD$3) / n : 0;
1508
+ const zero = px(y(0));
1509
+ input.data.forEach((v, i) => {
1510
+ if (!isFinite(v))
1511
+ return;
1512
+ const top = px(y(v));
1513
+ shapes.push({
1514
+ kind: 'rect',
1515
+ part: 'bar',
1516
+ x: px(PAD$3 + i * slot + slot * 0.1),
1517
+ y: Math.min(top, zero),
1518
+ width: px(Math.max(1, slot * 0.8)),
1519
+ height: px(Math.max(1, Math.abs(zero - top))),
1520
+ fill: v < 0 && input.trend ? CHART_COLORS.negative : color,
1521
+ title: input.tooltip === false ? undefined : format(v),
1522
+ });
1523
+ });
1524
+ }
1525
+ else {
1526
+ const line = pathThrough(coords, input.curve);
1527
+ const head = coords[0];
1528
+ if (variant === 'area')
1529
+ shapes.push({
566
1530
  kind: 'path',
567
1531
  part: 'area',
568
- d: line && first && last
569
- ? `${line}L${last.x} ${height - PAD$3}L${first.x} ${height - PAD$3}Z`
1532
+ d: line && head && tail
1533
+ ? `${line}L${tail.x} ${height - PAD$3}L${head.x} ${height - PAD$3}Z`
570
1534
  : '',
571
1535
  fill: color,
572
1536
  fillOpacity: 0.15,
573
- },
574
- { kind: 'path', part: 'line', d: line, stroke: color, strokeWidth: 1.5 },
575
- // The most recent point, drawn as a dot.
576
- ...(last ? [point(last.x, last.y, 2, color)] : []),
577
- ],
1537
+ });
1538
+ shapes.push({ kind: 'path', part: 'line', d: line, stroke: color, strokeWidth: 1.5 });
1539
+ // The most recent point, drawn as a dot.
1540
+ if (tail)
1541
+ shapes.push(point(tail.x, tail.y, 2, color, undefined));
1542
+ }
1543
+ if (input.extremes && coords.length > 1) {
1544
+ const min = coords.reduce((a, b) => (b.v < a.v ? b : a));
1545
+ const max = coords.reduce((a, b) => (b.v > a.v ? b : a));
1546
+ shapes.push({ ...point(min.x, min.y, 2.5, CHART_COLORS.negative, format(min.v)), part: 'min' }, { ...point(max.x, max.y, 2.5, CHART_COLORS.positive, format(max.v)), part: 'max' });
1547
+ }
1548
+ if (lastText && tail)
1549
+ shapes.push(label(px(right + 4), tail.y, lastText, {
1550
+ part: 'last-value',
1551
+ baseline: 'middle',
1552
+ fill: 'var(--skdx-color-text-default)',
1553
+ }));
1554
+ return {
1555
+ width,
1556
+ height,
1557
+ tooltip: input.tooltip ?? true,
1558
+ description: input.description,
1559
+ decorative: input.decorative,
1560
+ animate: input.animate === true ? 300 : input.animate || undefined,
1561
+ responsive: input.responsive,
1562
+ shapes,
578
1563
  };
579
1564
  }
580
1565
 
@@ -584,6 +1569,10 @@ function layoutSparkline(input) {
584
1569
  * Licensed under the MIT License. See LICENSE in the repository root.
585
1570
  */
586
1571
  function layoutRadarChart(input) {
1572
+ const state = stateCanvas(input, input.categories.length === 0 || input.series.every((s) => finite(s.data).length === 0), 300, 300);
1573
+ if (state)
1574
+ return state;
1575
+ const format = makeFormatter(input.format);
587
1576
  const { width, height } = size(input, 300, 300);
588
1577
  const top = legendTop(input);
589
1578
  const cx = width / 2;
@@ -591,19 +1580,20 @@ function layoutRadarChart(input) {
591
1580
  const radius = Math.max(0, Math.min(width, height - top) / 2 - 36);
592
1581
  const n = input.categories.length;
593
1582
  const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / Math.max(1, n);
1583
+ const shown = input.series.filter((s) => !isHidden(input, s.name));
594
1584
  // Normalized: every series is scaled to its own maximum and the rings read as percentages.
595
1585
  const ticks = input.normalize
596
1586
  ? [0, 25, 50, 75, 100]
597
- : niceTicks(0, Math.max(0, ...input.series.flatMap((s) => s.data)), 4);
598
- const max = ticks[ticks.length - 1];
599
- const seriesMax = input.series.map((s) => Math.max(0, ...s.data));
1587
+ : niceTicks(0, input.max ?? Math.max(0, minMax(shown.flatMap((s) => s.data))[1]), 4);
1588
+ const max = input.max ?? ticks[ticks.length - 1];
1589
+ const seriesMax = input.series.map((s) => Math.max(0, minMax(s.data)[1]));
600
1590
  const norm = (si, v) => input.normalize
601
1591
  ? seriesMax[si] > 0
602
1592
  ? (v / seriesMax[si]) * 100
603
1593
  : 0
604
1594
  : v;
605
1595
  const at = (i, value) => {
606
- const r = max > 0 ? (value / max) * radius : 0;
1596
+ const r = max > 0 ? (Math.min(value, max) / max) * radius : 0;
607
1597
  return { x: px(cx + r * Math.cos(angle(i))), y: px(cy + r * Math.sin(angle(i))) };
608
1598
  };
609
1599
  const polygon = (value) => n === 0
@@ -615,7 +1605,7 @@ function layoutRadarChart(input) {
615
1605
  })
616
1606
  .join('L')}Z`;
617
1607
  // Concentric grid polygons, one per tick, innermost first, then a spoke per category.
618
- const rings = ticks.filter((t) => t > 0);
1608
+ const rings = ticks.filter((t) => t > 0 && t <= max);
619
1609
  const grid = [
620
1610
  ...rings.map((t) => ({
621
1611
  kind: 'path',
@@ -627,48 +1617,654 @@ function layoutRadarChart(input) {
627
1617
  const p = at(i, max);
628
1618
  return {
629
1619
  kind: 'line',
630
- part: 'grid',
631
- x1: px(cx),
632
- y1: px(cy),
633
- x2: p.x,
634
- y2: p.y,
635
- stroke: CHART_COLORS.grid,
1620
+ part: 'grid',
1621
+ x1: px(cx),
1622
+ y1: px(cy),
1623
+ x2: p.x,
1624
+ y2: p.y,
1625
+ stroke: CHART_COLORS.grid,
1626
+ };
1627
+ }),
1628
+ ...rings.map((t) => label(px(cx + 4), at(0, t).y, input.normalize ? `${t}%` : format(t), {
1629
+ part: 'y-label',
1630
+ baseline: 'middle',
1631
+ })),
1632
+ ...input.categories.map((text, i) => {
1633
+ const cos = Math.cos(angle(i));
1634
+ const r = radius + 14;
1635
+ return label(px(cx + r * cos), px(cy + r * Math.sin(angle(i))), text, {
1636
+ part: 'x-label',
1637
+ anchor: cos > 0.1 ? 'start' : cos < -0.1 ? 'end' : 'middle',
1638
+ baseline: 'middle',
1639
+ });
1640
+ }),
1641
+ ];
1642
+ const points = [];
1643
+ const polygons = [];
1644
+ input.series.forEach((s, si) => {
1645
+ if (isHidden(input, s.name))
1646
+ return;
1647
+ const color = colorOf(input, si, s.color);
1648
+ const value = (i) => {
1649
+ const v = s.data[i];
1650
+ return isFinite(v) ? v : 0;
1651
+ };
1652
+ input.categories.forEach((category, i) => {
1653
+ const raw = s.data[i];
1654
+ if (!isFinite(raw))
1655
+ return;
1656
+ const p = at(i, norm(si, raw));
1657
+ const title = markTitle(s.name, category, raw, format);
1658
+ points.push(point(p.x, p.y, 3, color, title, {
1659
+ label: title,
1660
+ series: s.name,
1661
+ category,
1662
+ index: i,
1663
+ value: raw,
1664
+ }));
1665
+ });
1666
+ polygons.push({
1667
+ kind: 'path',
1668
+ part: 'area',
1669
+ d: polygon((i) => norm(si, value(i))),
1670
+ fill: (input.fill ?? true) ? color : undefined,
1671
+ fillOpacity: 0.25,
1672
+ stroke: color,
1673
+ strokeWidth: 2,
1674
+ });
1675
+ });
1676
+ const c = canvas(input, width, height, input.series.map((s, i) => ({ name: s.name, color: colorOf(input, i, s.color) })));
1677
+ return { ...c, shapes: [...grid, ...polygons, ...points, ...c.shapes] };
1678
+ }
1679
+
1680
+ /*
1681
+ * Copyright (c) 2026 SkandaDX
1682
+ * This file is part of the SkandaDX organization.
1683
+ * Licensed under the MIT License. See LICENSE in the repository root.
1684
+ */
1685
+ const MARGIN = { top: 24, right: 8, bottom: 8, left: 64 };
1686
+ const DAY = 86_400_000;
1687
+ const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
1688
+ /** Days as a weekday × week matrix, columns labelled where a month starts. */
1689
+ function calendarMatrix(cells) {
1690
+ const days = cells
1691
+ .map((c) => ({ day: Math.floor(new Date(c.date).getTime() / DAY), value: c.value }))
1692
+ .filter((c) => Number.isFinite(c.day));
1693
+ if (days.length === 0)
1694
+ return {
1695
+ rows: WEEKDAYS,
1696
+ columns: [],
1697
+ data: WEEKDAYS.map(() => []),
1698
+ dates: WEEKDAYS.map(() => []),
1699
+ };
1700
+ const [first, last] = minMax(days.map((d) => d.day));
1701
+ // Monday-based weekday: epoch day 0 (1970-01-01) was a Thursday.
1702
+ const weekday = (day) => (((day + 3) % 7) + 7) % 7;
1703
+ const startWeek = first - weekday(first);
1704
+ const weeks = Math.floor((last - startWeek) / 7) + 1;
1705
+ const data = WEEKDAYS.map(() => new Array(weeks).fill(null));
1706
+ const dates = WEEKDAYS.map(() => new Array(weeks).fill(undefined));
1707
+ for (const d of days) {
1708
+ const w = Math.floor((d.day - startWeek) / 7);
1709
+ data[weekday(d.day)][w] = d.value;
1710
+ dates[weekday(d.day)][w] = new Date(d.day * DAY)
1711
+ .toISOString()
1712
+ .slice(0, 10);
1713
+ }
1714
+ const months = new Intl.DateTimeFormat(undefined, { month: 'short' });
1715
+ let lastMonth = -1;
1716
+ const columns = Array.from({ length: weeks }, (_, w) => {
1717
+ const date = new Date((startWeek + w * 7 + 6) * DAY);
1718
+ const m = date.getUTCMonth();
1719
+ if (m === lastMonth)
1720
+ return '';
1721
+ lastMonth = m;
1722
+ return months.format(date);
1723
+ });
1724
+ return { rows: WEEKDAYS, columns, data, dates };
1725
+ }
1726
+ function layoutHeatmap(input) {
1727
+ const cal = input.calendar ? calendarMatrix(input.calendar) : undefined;
1728
+ const rows = cal?.rows ?? input.rows ?? [];
1729
+ const columns = cal?.columns ?? input.columns ?? [];
1730
+ const data = cal?.data ?? input.data ?? [];
1731
+ const all = data.flat();
1732
+ const state = stateCanvas(input, rows.length === 0 || columns.length === 0 || !all.some(isFinite));
1733
+ if (state)
1734
+ return state;
1735
+ const format = makeFormatter(input.format);
1736
+ const { width, height } = size(input);
1737
+ const plot = plotBox(width, height, cal ? { ...MARGIN, left: 40 } : MARGIN);
1738
+ const cellW = plot.width / Math.max(1, columns.length);
1739
+ const cellH = plot.height / Math.max(1, rows.length);
1740
+ const [min, max] = input.domain ?? minMax(all);
1741
+ const color = colorOf(input, 0, input.color);
1742
+ const negative = input.negativeColor ?? 'var(--skdx-color-danger-500)';
1743
+ const span = Math.max(Math.abs(min), Math.abs(max));
1744
+ // Diverging: opacity is the distance from zero, color the sign.
1745
+ // Otherwise 0.15..1, the value's place between the smallest and largest cell.
1746
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
1747
+ const opacity = (v) => input.diverging
1748
+ ? span === 0
1749
+ ? 1
1750
+ : px(0.15 + 0.85 * clamp01(Math.abs(v) / span))
1751
+ : max === min
1752
+ ? 1
1753
+ : px(0.15 + 0.85 * clamp01((v - min) / (max - min)));
1754
+ const cells = [];
1755
+ const texts = [];
1756
+ const gap = cal ? 1 : 0;
1757
+ rows.forEach((row, ri) => {
1758
+ columns.forEach((column, ci) => {
1759
+ const value = data[ri]?.[ci];
1760
+ if (!isFinite(value))
1761
+ return;
1762
+ const where = cal ? (cal.dates[ri]?.[ci] ?? row) : `${row}, ${column}`;
1763
+ const title = `${where}: ${format(value)}`;
1764
+ const x = px(plot.x + ci * cellW + gap);
1765
+ const y = px(plot.y + ri * cellH + gap);
1766
+ cells.push({
1767
+ kind: 'rect',
1768
+ part: 'cell',
1769
+ x,
1770
+ y,
1771
+ width: px(Math.max(0, cellW - 2 * gap)),
1772
+ height: px(Math.max(0, cellH - 2 * gap)),
1773
+ fill: input.diverging && value < 0 ? negative : color,
1774
+ opacity: opacity(value),
1775
+ rx: cal ? 2 : undefined,
1776
+ title,
1777
+ item: { label: title, category: column, name: row, index: ci, value },
1778
+ });
1779
+ if (input.labels && cellW > 24 && cellH > 12)
1780
+ texts.push(label(px(plot.x + (ci + 0.5) * cellW), px(plot.y + (ri + 0.5) * cellH), format(value), {
1781
+ part: 'cell-label',
1782
+ anchor: 'middle',
1783
+ baseline: 'middle',
1784
+ fill: 'var(--skdx-color-text-default)',
1785
+ }));
1786
+ });
1787
+ });
1788
+ const c = canvas(input, width, height);
1789
+ return {
1790
+ ...c,
1791
+ shapes: [
1792
+ ...rows.map((text, i) => label(plot.x - 8, px(plot.y + (i + 0.5) * cellH), text, {
1793
+ part: 'y-label',
1794
+ anchor: 'end',
1795
+ baseline: 'middle',
1796
+ })),
1797
+ ...columns.flatMap((text, i) => text
1798
+ ? [
1799
+ label(px(plot.x + (i + 0.5) * cellW), plot.y - 8, text, {
1800
+ part: 'x-label',
1801
+ anchor: cal ? 'start' : 'middle',
1802
+ }),
1803
+ ]
1804
+ : []),
1805
+ ...cells,
1806
+ ...texts,
1807
+ ...c.shapes,
1808
+ ],
1809
+ };
1810
+ }
1811
+
1812
+ /*
1813
+ * Copyright (c) 2026 SkandaDX
1814
+ * This file is part of the SkandaDX organization.
1815
+ * Licensed under the MIT License. See LICENSE in the repository root.
1816
+ */
1817
+ const PAD$2 = 8;
1818
+ const GAP = 4;
1819
+ /**
1820
+ * Stages narrowing with their value (conversion), or a pyramid widening toward the end
1821
+ * (shares of a whole). Vertical by default, horizontal when asked.
1822
+ */
1823
+ function layoutFunnelChart(input) {
1824
+ const pyramid = input.variant === 'pyramid';
1825
+ // A pyramid widens toward its base: smallest stage first, shares of the total instead of conversion.
1826
+ const data = input.data
1827
+ .filter((d) => isFinite(d.value))
1828
+ .sort((a, b) => (pyramid ? a.value - b.value : 0));
1829
+ const state = stateCanvas(input, data.length === 0, 400, 300);
1830
+ if (state)
1831
+ return state;
1832
+ const format = makeFormatter(input.format);
1833
+ const { width, height } = size(input, 400, 300);
1834
+ const n = data.length;
1835
+ const horizontal = input.orientation === 'horizontal';
1836
+ const max = Math.max(0, ...data.map((d) => d.value));
1837
+ const total = data.reduce((s, d) => s + Math.max(0, d.value), 0);
1838
+ const first = data[0]?.value ?? 0;
1839
+ const offset = legendTop(input);
1840
+ // Along: the axis stages are stacked on; across: the axis a stage's width is measured on.
1841
+ const along = horizontal ? width : height - offset;
1842
+ const across = horizontal ? height - offset : width;
1843
+ const full = Math.max(0, across - 2 * PAD$2);
1844
+ const stageLen = n === 0 ? 0 : (along - 2 * PAD$2) / n;
1845
+ const widthOf = (value) => (max > 0 ? (Math.max(0, value) / max) * full : 0);
1846
+ const legend = data.map((d, i) => ({ name: d.name, color: colorOf(input, i, d.color) }));
1847
+ const mid = horizontal ? offset + (height - offset) / 2 : width / 2;
1848
+ const stages = data.flatMap((d, i) => {
1849
+ const near = widthOf(d.value);
1850
+ const far = widthOf(data[i + 1]?.value ?? d.value);
1851
+ const p0 = PAD$2 + i * stageLen + (horizontal ? 0 : offset);
1852
+ const p1 = p0 + stageLen - GAP;
1853
+ const percent = pyramid ? (total > 0 ? d.value / total : 0) : first > 0 ? d.value / first : 0;
1854
+ const previous = data[i - 1]?.value;
1855
+ const drop = !pyramid && previous !== undefined && previous > 0
1856
+ ? ` · −${Math.round((1 - d.value / previous) * 100)}% from ${data[i - 1]?.name}`
1857
+ : '';
1858
+ const title = `${d.name}: ${format(d.value)} (${Math.round(percent * 100)}%)${drop}`;
1859
+ const d0 = horizontal
1860
+ ? `M${px(p0)} ${px(mid - near / 2)}L${px(p0)} ${px(mid + near / 2)}L${px(p1)} ${px(mid + far / 2)}L${px(p1)} ${px(mid - far / 2)}Z`
1861
+ : `M${px(mid - near / 2)} ${px(p0)}L${px(mid + near / 2)} ${px(p0)}L${px(mid + far / 2)} ${px(p1)}L${px(mid - far / 2)} ${px(p1)}Z`;
1862
+ return [
1863
+ {
1864
+ kind: 'path',
1865
+ part: 'stage',
1866
+ d: d0,
1867
+ fill: colorOf(input, i, d.color),
1868
+ title,
1869
+ item: { label: title, name: d.name, index: i, value: d.value },
1870
+ },
1871
+ label(px(horizontal ? (p0 + p1) / 2 : mid), px(horizontal ? mid : (p0 + p1) / 2), `${d.name} · ${format(d.value)}`, {
1872
+ part: 'stage-label',
1873
+ anchor: 'middle',
1874
+ baseline: 'middle',
1875
+ fill: 'var(--skdx-color-text-on-accent)',
1876
+ }),
1877
+ ];
1878
+ });
1879
+ const c = canvas(input, width, height, legend);
1880
+ return { ...c, shapes: [...stages, ...c.shapes] };
1881
+ }
1882
+
1883
+ /*
1884
+ * Copyright (c) 2026 SkandaDX
1885
+ * This file is part of the SkandaDX organization.
1886
+ * Licensed under the MIT License. See LICENSE in the repository root.
1887
+ */
1888
+ const rad = (deg) => (deg * Math.PI) / 180;
1889
+ /**
1890
+ * Concentric arcs, one per datum, each a share of `max`: KPI completion, utilization,
1891
+ * quota attainment. `bands` and `target` turn it into a gauge; `startAngle`/`endAngle` open it.
1892
+ */
1893
+ function layoutRadialBar(input) {
1894
+ const data = input.data.filter((d) => isFinite(d.value));
1895
+ const state = stateCanvas(input, data.length === 0, 300, 300);
1896
+ if (state)
1897
+ return state;
1898
+ const format = makeFormatter(input.format);
1899
+ const { width, height } = size(input, 300, 300);
1900
+ const top = legendTop(input);
1901
+ const cx = width / 2;
1902
+ const cy = top + (height - top) / 2;
1903
+ const outer = Math.max(0, Math.min(width, height - top) / 2 - (input.bands ? 20 : 8));
1904
+ const inner = outer * Math.min(0.95, Math.max(0, input.innerRadius ?? 0.3));
1905
+ const start = rad(input.startAngle ?? 0);
1906
+ const end = rad(input.endAngle ?? (input.startAngle ?? 0) + 360);
1907
+ const sweep = Math.max(0, Math.min(2 * Math.PI, end - start));
1908
+ const max = input.max ?? Math.max(0, ...data.map((d) => d.value));
1909
+ const n = data.length;
1910
+ const gap = Math.max(0, input.gap ?? 0.25);
1911
+ const thickness = n === 0 ? 0 : (outer - inner) / (n + gap * (n - 1));
1912
+ const rounded = input.rounded ?? true;
1913
+ const ratio = (v) => (max > 0 ? Math.max(0, Math.min(1, v / max)) : 0);
1914
+ const shapes = [];
1915
+ const legend = data.map((d, i) => ({ name: d.name, color: colorOf(input, i, d.color) }));
1916
+ // Threshold sectors outside the arcs, e.g. poor / fair / good.
1917
+ for (const b of input.bands ?? []) {
1918
+ const a0 = start + ratio(b.from) * sweep;
1919
+ const a1 = start + ratio(b.to) * sweep;
1920
+ if (a1 <= a0)
1921
+ continue;
1922
+ shapes.push({
1923
+ kind: 'path',
1924
+ part: 'band',
1925
+ d: arc(cx, cy, outer + 14, outer + 6, a0, a1),
1926
+ fill: variantColor(b.variant),
1927
+ opacity: 0.6,
1928
+ title: `${format(b.from)} – ${format(b.to)}`,
1929
+ });
1930
+ }
1931
+ data.forEach((d, i) => {
1932
+ if (isHidden(input, d.name))
1933
+ return;
1934
+ const r1 = outer - i * thickness * (1 + gap);
1935
+ const r0 = r1 - thickness;
1936
+ const color = colorOf(input, i, d.color);
1937
+ const a1 = start + ratio(d.value) * sweep;
1938
+ const title = `${d.name}: ${format(d.value)} (${Math.round(ratio(d.value) * 100)}%)`;
1939
+ if (input.track ?? true)
1940
+ shapes.push({
1941
+ kind: 'path',
1942
+ part: 'track',
1943
+ d: arc(cx, cy, r1, r0, start, start + sweep),
1944
+ fill: CHART_COLORS.grid,
1945
+ opacity: 0.6,
1946
+ });
1947
+ if (a1 > start) {
1948
+ shapes.push({
1949
+ kind: 'path',
1950
+ part: 'bar',
1951
+ d: arc(cx, cy, r1, r0, start, a1),
1952
+ fill: color,
1953
+ title,
1954
+ item: { label: title, name: d.name, index: i, value: d.value },
1955
+ });
1956
+ if (rounded && sweep < 2 * Math.PI - 1e-9) {
1957
+ const rm = (r0 + r1) / 2;
1958
+ for (const a of [start, a1])
1959
+ shapes.push({
1960
+ kind: 'circle',
1961
+ part: 'bar-cap',
1962
+ cx: px(cx + rm * Math.sin(a)),
1963
+ cy: px(cy - rm * Math.cos(a)),
1964
+ r: px(thickness / 2),
1965
+ fill: color,
1966
+ });
1967
+ }
1968
+ }
1969
+ if (input.labels) {
1970
+ const rm = (r0 + r1) / 2;
1971
+ shapes.push(label(px(cx + rm * Math.sin(start) - 6 * Math.cos(start)), px(cy - rm * Math.cos(start) - 6 * Math.sin(start)), `${d.name} ${format(d.value)}`, { part: 'bar-label', anchor: Math.cos(start) >= 0 ? 'end' : 'start', baseline: 'middle' }));
1972
+ }
1973
+ });
1974
+ if (input.target !== undefined && isFinite(input.target)) {
1975
+ const a = start + ratio(input.target) * sweep;
1976
+ const rIn = outer - n * thickness * (1 + gap) + thickness * gap;
1977
+ shapes.push({
1978
+ kind: 'line',
1979
+ part: 'target',
1980
+ x1: px(cx + rIn * Math.sin(a)),
1981
+ y1: px(cy - rIn * Math.cos(a)),
1982
+ x2: px(cx + (outer + 4) * Math.sin(a)),
1983
+ y2: px(cy - (outer + 4) * Math.cos(a)),
1984
+ stroke: 'var(--skdx-color-text-default)',
1985
+ strokeWidth: 2,
1986
+ title: `Target: ${format(input.target)}`,
1987
+ });
1988
+ }
1989
+ if (input.centerLabel)
1990
+ shapes.push(label(px(cx), px(cy), input.centerLabel, {
1991
+ part: 'center-label',
1992
+ anchor: 'middle',
1993
+ baseline: 'middle',
1994
+ fill: 'var(--skdx-color-text-default)',
1995
+ weight: 'bold',
1996
+ }));
1997
+ const c = canvas(input, width, height, legend);
1998
+ return { ...c, shapes: [...shapes, ...c.shapes] };
1999
+ }
2000
+
2001
+ /*
2002
+ * Copyright (c) 2026 SkandaDX
2003
+ * This file is part of the SkandaDX organization.
2004
+ * Licensed under the MIT License. See LICENSE in the repository root.
2005
+ */
2006
+ /*
2007
+ * Pure interaction helpers shared by every framework root: the roots hold the state
2008
+ * (tooltip shapes, focused mark, hidden series, zoom) and call these on events, so
2009
+ * hit-testing, keyboard stepping and zoom math cannot drift between frameworks.
2010
+ */
2011
+ /** Converts a client position into viewBox units of the svg. */
2012
+ function toViewBox(svg, clientX, clientY, width, height) {
2013
+ const r = svg.getBoundingClientRect();
2014
+ return {
2015
+ x: r.width ? ((clientX - r.left) * width) / r.width : clientX,
2016
+ y: r.height ? ((clientY - r.top) * height) / r.height : clientY,
2017
+ };
2018
+ }
2019
+ const inside = (b, x, y) => x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
2020
+ /** Index of the visible category whose center is nearest to viewBox `x`. */
2021
+ function nearestIndex(hit, x) {
2022
+ let best = 0;
2023
+ let dist = Infinity;
2024
+ hit.xs.forEach((cx, i) => {
2025
+ const d = Math.abs(cx - x);
2026
+ if (d < dist) {
2027
+ dist = d;
2028
+ best = i;
2029
+ }
2030
+ });
2031
+ return best;
2032
+ }
2033
+ /** The crosshair line and shared tooltip at the category nearest to `x`, or none outside the plot. */
2034
+ function crosshairShapes(layout, x, y) {
2035
+ const hit = layout.hit;
2036
+ if (!hit || !hit.crosshair || hit.xs.length === 0 || !inside(hit.plot, x, y))
2037
+ return [];
2038
+ const i = nearestIndex(hit, x);
2039
+ const cx = hit.xs[i];
2040
+ return [
2041
+ {
2042
+ kind: 'line',
2043
+ part: 'crosshair',
2044
+ x1: cx,
2045
+ x2: cx,
2046
+ y1: hit.plot.y,
2047
+ y2: hit.plot.y + hit.plot.height,
2048
+ stroke: CHART_COLORS.axis,
2049
+ dash: '3 3',
2050
+ },
2051
+ ...tooltipShapes(cx, y, hit.rows[i] ?? [], layout.width, layout.height),
2052
+ ];
2053
+ }
2054
+ /**
2055
+ * Tooltip shapes for the mark under the pointer, or none. Reads the mark's
2056
+ * `<title>` and converts the pointer position into viewBox units.
2057
+ */
2058
+ function hoverTooltip(target, svg, clientX, clientY, width, height) {
2059
+ const mark = target?.closest?.('[data-skdx-part]');
2060
+ const title = mark && Array.from(mark.children).find((c) => c.tagName.toLowerCase() === 'title')?.textContent;
2061
+ if (!title)
2062
+ return [];
2063
+ const { x, y } = toViewBox(svg, clientX, clientY, width, height);
2064
+ return tooltipShapes(x, y, title, width, height);
2065
+ }
2066
+ /** What to draw for a pointer at the given position: the crosshair when on, else the hovered mark's tooltip. */
2067
+ function pointerShapes(layout, target, svg, clientX, clientY) {
2068
+ if (!layout.tooltip)
2069
+ return [];
2070
+ if (layout.hit?.crosshair) {
2071
+ const { x, y } = toViewBox(svg, clientX, clientY, layout.width, layout.height);
2072
+ const cross = crosshairShapes(layout, x, y);
2073
+ if (cross.length > 0)
2074
+ return cross;
2075
+ }
2076
+ return hoverTooltip(target, svg, clientX, clientY, layout.width, layout.height);
2077
+ }
2078
+ /** The shape index and item of the mark an event landed on, if it stands for something. */
2079
+ function itemAt(layout, target) {
2080
+ const mark = target?.closest?.('[data-skdx-i]');
2081
+ const i = Number(mark?.getAttribute('data-skdx-i'));
2082
+ const item = Number.isInteger(i) ? layout.shapes[i]?.item : undefined;
2083
+ return item ? { index: i, item } : undefined;
2084
+ }
2085
+ /** Indexes of the shapes a keyboard user can land on, in drawing order. */
2086
+ const focusTargets = (layout) => layout.shapes.flatMap((s, i) => (s.item && !s.item.legend ? [i] : []));
2087
+ /** A representative point of a shape, where its tooltip is anchored. */
2088
+ function anchorOf(s) {
2089
+ switch (s.kind) {
2090
+ case 'rect':
2091
+ return { x: s.x + s.width / 2, y: s.y };
2092
+ case 'circle':
2093
+ return { x: s.cx, y: s.cy - s.r };
2094
+ case 'line':
2095
+ return { x: (s.x1 + s.x2) / 2, y: Math.min(s.y1, s.y2) };
2096
+ case 'text':
2097
+ return { x: s.x, y: s.y };
2098
+ case 'path': {
2099
+ const m = /M\s*(-?[\d.]+)[ ,](-?[\d.]+)/.exec(s.d);
2100
+ return m ? { x: Number(m[1]), y: Number(m[2]) } : { x: 0, y: 0 };
2101
+ }
2102
+ }
2103
+ }
2104
+ /** The focus ring around a shape plus its tooltip, for keyboard users. */
2105
+ function focusShapes(layout, index) {
2106
+ const s = index === null ? undefined : layout.shapes[index];
2107
+ if (!s)
2108
+ return [];
2109
+ const ring = CHART_COLORS.focus;
2110
+ let outline;
2111
+ switch (s.kind) {
2112
+ case 'rect':
2113
+ outline = {
2114
+ kind: 'rect',
2115
+ part: 'focus',
2116
+ x: s.x - 2,
2117
+ y: s.y - 2,
2118
+ width: s.width + 4,
2119
+ height: s.height + 4,
2120
+ fill: 'none',
2121
+ stroke: ring,
2122
+ rx: 2,
2123
+ };
2124
+ break;
2125
+ case 'circle':
2126
+ outline = {
2127
+ kind: 'circle',
2128
+ part: 'focus',
2129
+ cx: s.cx,
2130
+ cy: s.cy,
2131
+ r: s.r + 3,
2132
+ fill: 'none',
2133
+ stroke: ring,
2134
+ };
2135
+ break;
2136
+ case 'line':
2137
+ outline = {
2138
+ kind: 'line',
2139
+ part: 'focus',
2140
+ x1: s.x1,
2141
+ y1: s.y1,
2142
+ x2: s.x2,
2143
+ y2: s.y2,
2144
+ stroke: ring,
2145
+ strokeWidth: 4,
636
2146
  };
637
- }),
638
- ...rings.map((t) => label(px(cx + 4), at(0, t).y, input.normalize ? `${t}%` : formatValue(t), {
639
- part: 'y-label',
640
- baseline: 'middle',
641
- })),
642
- ...input.categories.map((text, i) => {
643
- const cos = Math.cos(angle(i));
644
- const r = radius + 14;
645
- return label(px(cx + r * cos), px(cy + r * Math.sin(angle(i))), text, {
646
- part: 'x-label',
647
- anchor: cos > 0.1 ? 'start' : cos < -0.1 ? 'end' : 'middle',
648
- baseline: 'middle',
649
- });
650
- }),
651
- ];
652
- const points = [];
653
- const polygons = input.series.map((s, si) => {
654
- const color = seriesColor(si, s.color);
655
- input.categories.forEach((category, i) => {
656
- const value = s.data[i] ?? 0;
657
- const p = at(i, norm(si, value));
658
- points.push(point(p.x, p.y, 3, color, markTitle(s.name, category, value)));
659
- });
660
- return {
661
- kind: 'path',
662
- part: 'area',
663
- d: polygon((i) => norm(si, s.data[i] ?? 0)),
664
- fill: color,
665
- fillOpacity: 0.25,
666
- stroke: color,
667
- strokeWidth: 2,
668
- };
669
- });
670
- const c = canvas(input, width, height, input.series.map((s, i) => ({ name: s.name, color: seriesColor(i, s.color) })));
671
- return { ...c, shapes: [...grid, ...polygons, ...points, ...c.shapes] };
2147
+ break;
2148
+ case 'text':
2149
+ outline = {
2150
+ kind: 'rect',
2151
+ part: 'focus',
2152
+ x: s.x - 2,
2153
+ y: s.y - 8,
2154
+ width: s.text.length * CHAR + 4,
2155
+ height: 16,
2156
+ fill: 'none',
2157
+ stroke: ring,
2158
+ rx: 2,
2159
+ };
2160
+ break;
2161
+ case 'path':
2162
+ outline = { kind: 'path', part: 'focus', d: s.d, stroke: ring, strokeWidth: 2, fill: 'none' };
2163
+ break;
2164
+ }
2165
+ const { x, y } = anchorOf(s);
2166
+ const text = s.item?.label ?? s.title ?? '';
2167
+ return [outline, ...(text ? tooltipShapes(px(x), px(y), text, layout.width, layout.height) : [])];
2168
+ }
2169
+ /**
2170
+ * The mark to focus after a key press: arrows step, Home/End jump, Escape clears (`null`);
2171
+ * `undefined` means the key was not handled and should propagate.
2172
+ */
2173
+ function nextFocus(layout, current, key) {
2174
+ const targets = focusTargets(layout);
2175
+ if (targets.length === 0)
2176
+ return undefined;
2177
+ const at = current === null ? -1 : targets.indexOf(current);
2178
+ switch (key) {
2179
+ case 'ArrowRight':
2180
+ case 'ArrowDown':
2181
+ return targets[Math.min(targets.length - 1, at + 1)];
2182
+ case 'ArrowLeft':
2183
+ case 'ArrowUp':
2184
+ return targets[Math.max(0, at <= 0 ? 0 : at - 1)];
2185
+ case 'Home':
2186
+ return targets[0];
2187
+ case 'End':
2188
+ return targets[targets.length - 1];
2189
+ case 'Escape':
2190
+ return null;
2191
+ default:
2192
+ return undefined;
2193
+ }
2194
+ }
2195
+ const MIN_WINDOW = 2;
2196
+ const clampZoom = (start, span, count) => {
2197
+ const window = Math.max(MIN_WINDOW, Math.min(count, Math.round(span)));
2198
+ const s = Math.max(0, Math.min(count - window, Math.round(start)));
2199
+ return { start: s, end: s + window };
2200
+ };
2201
+ /** Whether the zoom shows everything. */
2202
+ const isUnzoomed = (zoom, count) => !zoom || (zoom.start <= 0 && zoom.end >= count);
2203
+ /** The window after a wheel step at viewBox `x`: scrolling up narrows around the pointer, down widens. */
2204
+ function zoomWheel(hit, deltaY, x) {
2205
+ if (hit.count <= MIN_WINDOW || !hit.plot.width)
2206
+ return undefined;
2207
+ const { start, end } = hit.zoom;
2208
+ const window = end - start;
2209
+ const factor = deltaY > 0 ? 1.25 : 0.8;
2210
+ const next = Math.max(MIN_WINDOW, Math.min(hit.count, Math.round(window * factor)));
2211
+ if (next === window)
2212
+ return deltaY > 0 && !isUnzoomed(hit.zoom, hit.count)
2213
+ ? clampZoom(start, next, hit.count)
2214
+ : undefined;
2215
+ const f = Math.max(0, Math.min(1, (x - hit.plot.x) / hit.plot.width));
2216
+ const anchor = start + f * window;
2217
+ return clampZoom(anchor - f * next, next, hit.count);
2218
+ }
2219
+ /**
2220
+ * The window after a pinch: `ratio` is the current finger distance over the distance when the
2221
+ * pinch began (`> 1` narrows the window), anchored at viewBox `x`.
2222
+ */
2223
+ function zoomPinch(hit, from, ratio, x) {
2224
+ const window = from.end - from.start;
2225
+ const next = Math.max(MIN_WINDOW, Math.min(hit.count, Math.round(window / Math.max(0.05, ratio))));
2226
+ const f = Math.max(0, Math.min(1, (x - hit.plot.x) / Math.max(1, hit.plot.width)));
2227
+ return clampZoom(from.start + f * window - f * next, next, hit.count);
2228
+ }
2229
+ /** The window after dragging the plot by `dx` viewBox pixels from where `from` was set. */
2230
+ function zoomPan(hit, from, dx) {
2231
+ const window = from.end - from.start;
2232
+ const perCategory = hit.plot.width / Math.max(1, window);
2233
+ return clampZoom(from.start - dx / perCategory, window, hit.count);
2234
+ }
2235
+ /**
2236
+ * What a pointer-down at viewBox (x, y) grabs in the range strip: a handle, the window,
2237
+ * or (outside the window) a window re-centred there. `undefined` outside the strip.
2238
+ */
2239
+ function brushGrab(hit, x, y) {
2240
+ const b = hit.brush;
2241
+ if (!b || !inside(b.box, x, y))
2242
+ return undefined;
2243
+ const w = b.window;
2244
+ if (Math.abs(x - w.x) <= 6)
2245
+ return { grab: 'left', zoom: hit.zoom };
2246
+ if (Math.abs(x - (w.x + w.width)) <= 6)
2247
+ return { grab: 'right', zoom: hit.zoom };
2248
+ if (x > w.x && x < w.x + w.width)
2249
+ return { grab: 'move', zoom: hit.zoom };
2250
+ const window = hit.zoom.end - hit.zoom.start;
2251
+ const at = ((x - b.box.x) / Math.max(1, b.box.width)) * hit.count;
2252
+ return { grab: 'move', zoom: clampZoom(at - window / 2, window, hit.count) };
2253
+ }
2254
+ /** The window after dragging a brush grab by `dx` viewBox pixels. */
2255
+ function brushDrag(hit, grab, from, dx) {
2256
+ const b = hit.brush;
2257
+ if (!b)
2258
+ return from;
2259
+ const categories = (dx / Math.max(1, b.box.width)) * hit.count;
2260
+ if (grab === 'move')
2261
+ return clampZoom(from.start + categories, from.end - from.start, hit.count);
2262
+ if (grab === 'left') {
2263
+ const start = Math.max(0, Math.min(from.end - MIN_WINDOW, Math.round(from.start + categories)));
2264
+ return { start, end: from.end };
2265
+ }
2266
+ const end = Math.min(hit.count, Math.max(from.start + MIN_WINDOW, Math.round(from.end + categories)));
2267
+ return { start: from.start, end };
672
2268
  }
673
2269
 
674
2270
  /*
@@ -676,63 +2272,89 @@ function layoutRadarChart(input) {
676
2272
  * This file is part of the SkandaDX organization.
677
2273
  * Licensed under the MIT License. See LICENSE in the repository root.
678
2274
  */
679
- const MARGIN = { top: 24, right: 8, bottom: 8, left: 64 };
680
- function layoutHeatmap(input) {
681
- const { width, height } = size(input);
682
- const plot = plotBox(width, height, MARGIN);
683
- const cellW = plot.width / Math.max(1, input.columns.length);
684
- const cellH = plot.height / Math.max(1, input.rows.length);
685
- const all = input.data.flat();
686
- const min = Math.min(...all);
687
- const max = Math.max(...all);
688
- const color = seriesColor(0, input.color);
689
- const negative = input.negativeColor ?? 'var(--skdx-color-danger-500)';
690
- const span = Math.max(Math.abs(min), Math.abs(max));
691
- // Diverging: opacity is the distance from zero, color the sign.
692
- // Otherwise 0.15..1, the value's place between the smallest and largest cell.
693
- const opacity = (v) => input.diverging
694
- ? span === 0
695
- ? 1
696
- : px(0.15 + (0.85 * Math.abs(v)) / span)
697
- : max === min
698
- ? 1
699
- : px(0.15 + (0.85 * (v - min)) / (max - min));
700
- const cells = [];
701
- input.rows.forEach((row, ri) => {
702
- input.columns.forEach((column, ci) => {
703
- const value = input.data[ri]?.[ci];
704
- if (value === undefined)
705
- return;
706
- cells.push({
707
- kind: 'rect',
708
- part: 'cell',
709
- x: px(plot.x + ci * cellW),
710
- y: px(plot.y + ri * cellH),
711
- width: px(cellW),
712
- height: px(cellH),
713
- fill: input.diverging && value < 0 ? negative : color,
714
- opacity: opacity(value),
715
- title: `${row}, ${column}: ${formatValue(value)}`,
716
- });
717
- });
2275
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
2276
+ const attrs = (pairs) => pairs
2277
+ .filter(([, v]) => v !== undefined && v !== '')
2278
+ .map(([k, v]) => ` ${k}="${esc(String(v))}"`)
2279
+ .join('');
2280
+ /** One shape as SVG markup. Shared by the export and usable for server rendering. */
2281
+ function shapeMarkup(s) {
2282
+ const title = s.title ? `<title>${esc(s.title)}</title>` : '';
2283
+ const part = ['data-skdx-part', s.part];
2284
+ switch (s.kind) {
2285
+ case 'path':
2286
+ return `<path${attrs([part, ['d', s.d], ['fill', s.fill ?? 'none'], ['stroke', s.stroke], ['stroke-width', s.strokeWidth], ['stroke-linecap', s.strokeLinecap], ['stroke-linejoin', s.strokeLinejoin], ['stroke-dasharray', s.dash], ['opacity', s.opacity], ['fill-opacity', s.fillOpacity]])}>${title}</path>`;
2287
+ case 'rect':
2288
+ return `<rect${attrs([part, ['x', s.x], ['y', s.y], ['width', s.width], ['height', s.height], ['rx', s.rx], ['fill', s.fill], ['stroke', s.stroke], ['fill-opacity', s.opacity]])}>${title}</rect>`;
2289
+ case 'line':
2290
+ return `<line${attrs([part, ['x1', s.x1], ['y1', s.y1], ['x2', s.x2], ['y2', s.y2], ['stroke', s.stroke], ['stroke-width', s.strokeWidth], ['stroke-dasharray', s.dash]])}>${title}</line>`;
2291
+ case 'circle':
2292
+ return `<circle${attrs([part, ['cx', s.cx], ['cy', s.cy], ['r', s.r], ['fill', s.fill], ['stroke', s.stroke]])}>${title}</circle>`;
2293
+ case 'text':
2294
+ return `<text${attrs([part, ['x', s.x], ['y', s.y], ['text-anchor', s.anchor], ['dominant-baseline', s.baseline], ['fill', s.fill], ['font-weight', s.weight]])}>${esc(s.text)}</text>`;
2295
+ }
2296
+ }
2297
+ /**
2298
+ * A standalone SVG document of a layout, for download or server rendering. Colors stay as
2299
+ * `var(--skdx-…)` references unless `resolve` maps them to concrete values, e.g. from
2300
+ * `getComputedStyle(document.documentElement).getPropertyValue(name)`.
2301
+ */
2302
+ function svgMarkup(layout, label, resolve) {
2303
+ let body = layout.shapes.map(shapeMarkup).join('');
2304
+ if (resolve)
2305
+ body = body.replace(/var\((--[\w-]+)\)/g, (m, name) => resolve(name).trim() || m);
2306
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}" role="img" aria-label="${esc(label)}" font-family="${esc(CHART_COLORS.fontFamily)}" font-size="${esc(CHART_COLORS.fontSize)}"><title>${esc(label)}</title>${body}</svg>`;
2307
+ }
2308
+ /**
2309
+ * Rasterizes SVG markup (from `svgMarkup`, with tokens resolved) to a PNG blob at `scale` device
2310
+ * pixels per unit. Browser only: needs `Image`, `document` and a canvas.
2311
+ */
2312
+ function svgToPng(markup, width, height, scale = 2, background) {
2313
+ return new Promise((resolve, reject) => {
2314
+ const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }));
2315
+ const img = new Image();
2316
+ img.onload = () => {
2317
+ URL.revokeObjectURL(url);
2318
+ const canvas = document.createElement('canvas');
2319
+ canvas.width = Math.round(width * scale);
2320
+ canvas.height = Math.round(height * scale);
2321
+ const ctx = canvas.getContext('2d');
2322
+ if (!ctx)
2323
+ return reject(new Error('canvas 2d context unavailable'));
2324
+ if (background) {
2325
+ ctx.fillStyle = background;
2326
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
2327
+ }
2328
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
2329
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png');
2330
+ };
2331
+ img.onerror = () => {
2332
+ URL.revokeObjectURL(url);
2333
+ reject(new Error('svg could not be decoded'));
2334
+ };
2335
+ img.src = url;
718
2336
  });
719
- const c = canvas(input, width, height);
720
- return {
721
- ...c,
722
- shapes: [
723
- ...input.rows.map((text, i) => label(plot.x - 8, px(plot.y + (i + 0.5) * cellH), text, {
724
- part: 'y-label',
725
- anchor: 'end',
726
- baseline: 'middle',
727
- })),
728
- ...input.columns.map((text, i) => label(px(plot.x + (i + 0.5) * cellW), plot.y - 8, text, {
729
- part: 'x-label',
730
- anchor: 'middle',
731
- })),
732
- ...cells,
733
- ...c.shapes,
734
- ],
2337
+ }
2338
+ /** RFC 4180 CSV of rows, for "download data" and clipboard copy. */
2339
+ function csv(rows) {
2340
+ const cell = (v) => {
2341
+ const s = v === null || v === undefined ? '' : String(v);
2342
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
735
2343
  };
2344
+ return rows.map((r) => r.map(cell).join(',')).join('\r\n');
2345
+ }
2346
+ /** Series and categories as CSV rows: a header of series names, one row per category. */
2347
+ function seriesCsv(series, categories = []) {
2348
+ const n = Math.max(categories.length, ...series.map((s) => s.data.length));
2349
+ const rows = [['', ...series.map((s) => s.name)]];
2350
+ for (let i = 0; i < n; i++) {
2351
+ const c = categories[i];
2352
+ rows.push([
2353
+ c instanceof Date ? c.toISOString() : (c ?? i + 1),
2354
+ ...series.map((s) => s.data[i]),
2355
+ ]);
2356
+ }
2357
+ return csv(rows);
736
2358
  }
737
2359
 
738
2360
  /*
@@ -740,43 +2362,34 @@ function layoutHeatmap(input) {
740
2362
  * This file is part of the SkandaDX organization.
741
2363
  * Licensed under the MIT License. See LICENSE in the repository root.
742
2364
  */
743
- const PAD$2 = 8;
744
- const GAP = 4;
745
- function layoutFunnelChart(input) {
746
- const { width, height } = size(input, 400, 300);
747
- const n = input.data.length;
748
- const max = Math.max(0, ...input.data.map((d) => d.value));
749
- const first = input.data[0]?.value ?? 0;
750
- const full = Math.max(0, width - 2 * PAD$2);
751
- const offset = legendTop(input);
752
- const stageH = n === 0 ? 0 : (height - offset - 2 * PAD$2) / n;
753
- const widthOf = (value) => (max > 0 ? (Math.max(0, value) / max) * full : 0);
754
- const legend = input.data.map((d, i) => ({ name: d.name, color: seriesColor(i, d.color) }));
755
- const stages = input.data.flatMap((d, i) => {
756
- const top = widthOf(d.value);
757
- const bottom = widthOf(input.data[i + 1]?.value ?? d.value);
758
- const y0 = offset + PAD$2 + i * stageH;
759
- const y1 = y0 + stageH - GAP;
760
- const cx = width / 2;
761
- const percent = first > 0 ? d.value / first : 0;
762
- return [
763
- {
764
- kind: 'path',
765
- part: 'stage',
766
- d: `M${px(cx - top / 2)} ${px(y0)}L${px(cx + top / 2)} ${px(y0)}L${px(cx + bottom / 2)} ${px(y1)}L${px(cx - bottom / 2)} ${px(y1)}Z`,
767
- fill: seriesColor(i, d.color),
768
- title: `${d.name}: ${formatValue(d.value)} (${Math.round(percent * 100)}%)`,
769
- },
770
- label(px(cx), px((y0 + y1) / 2), `${d.name} · ${formatValue(d.value)}`, {
771
- part: 'stage-label',
772
- anchor: 'middle',
773
- baseline: 'middle',
774
- fill: 'var(--skdx-color-text-on-accent)',
775
- }),
776
- ];
777
- });
778
- const c = canvas(input, width, height, legend);
779
- return { ...c, shapes: [...stages, ...c.shapes] };
2365
+ /** Above this many shapes animation switches itself off; the transitions would cost more than they explain. */
2366
+ const ANIMATION_LIMIT = 1500;
2367
+ const MARKS = '[data-skdx-part]:not([data-skdx-part^="tooltip"]):not([data-skdx-part="focus"]):not([data-skdx-part="crosshair"])';
2368
+ /**
2369
+ * The static stylesheet every root ships: marks fade in on mount and their geometry transitions on
2370
+ * change, both over `--skdx-chart-duration`, which is `0ms` (nothing moves) unless the root sets it
2371
+ * from `animationDuration`. Tooltips, focus rings and the crosshair are excluded so interaction
2372
+ * stays immediate; `prefers-reduced-motion` turns everything off. Geometry transitions rely on
2373
+ * SVG 2 presentation attributes as CSS properties, which evergreen browsers support.
2374
+ */
2375
+ const ANIMATION_CSS = `@keyframes skdx-in{from{opacity:0}to{opacity:1}}${MARKS}{animation:skdx-in var(--skdx-chart-duration,0ms) ease-out;transition:${[
2376
+ 'x',
2377
+ 'y',
2378
+ 'width',
2379
+ 'height',
2380
+ 'cx',
2381
+ 'cy',
2382
+ 'r',
2383
+ 'opacity',
2384
+ 'fill-opacity',
2385
+ 'd',
2386
+ ]
2387
+ .map((p) => `${p} var(--skdx-chart-duration,0ms) ease-out`)
2388
+ .join(',')}}@media (prefers-reduced-motion:reduce){${MARKS}{animation:none;transition:none}}`;
2389
+ /** The `--skdx-chart-duration` value for a layout, e.g. `300ms`, or `undefined` when it must not animate. */
2390
+ function animationDuration(layout) {
2391
+ const ms = layout.animate;
2392
+ return ms && layout.shapes.length <= ANIMATION_LIMIT ? `${ms}ms` : undefined;
780
2393
  }
781
2394
 
782
2395
  /*
@@ -787,6 +2400,11 @@ function layoutFunnelChart(input) {
787
2400
  const NODE_WIDTH = 16;
788
2401
  const NODE_PAD = 8;
789
2402
  function layoutSankey(input) {
2403
+ const usable = input.links.filter((l) => isFinite(l.value) && l.value > 0 && l.source !== l.target);
2404
+ const state = stateCanvas(input, usable.length === 0);
2405
+ if (state)
2406
+ return state;
2407
+ const formatValue = makeFormatter(input.format);
790
2408
  const { width, height } = size(input);
791
2409
  const plot = plotBox(width, height, { top: 8 + legendTop(input), right: 96, bottom: 8, left: 8 });
792
2410
  const nodes = new Map();
@@ -797,7 +2415,7 @@ function layoutSankey(input) {
797
2415
  name,
798
2416
  depth: 0,
799
2417
  value: 0,
800
- color: seriesColor(nodes.size),
2418
+ color: colorOf(input, nodes.size),
801
2419
  x: 0,
802
2420
  y: 0,
803
2421
  height: 0,
@@ -808,7 +2426,7 @@ function layoutSankey(input) {
808
2426
  }
809
2427
  return n;
810
2428
  };
811
- const links = dropBackEdges(input.links.filter((l) => l.value > 0 && l.source !== l.target));
2429
+ const links = dropBackEdges(usable);
812
2430
  for (const l of links) {
813
2431
  node(l.source);
814
2432
  node(l.target);
@@ -866,6 +2484,12 @@ function layoutSankey(input) {
866
2484
  fill: s.color,
867
2485
  opacity: 0.4,
868
2486
  title: `${l.source} → ${l.target}: ${formatValue(l.value)}`,
2487
+ item: {
2488
+ label: `${l.source} → ${l.target}: ${formatValue(l.value)}`,
2489
+ name: l.source,
2490
+ category: l.target,
2491
+ value: l.value,
2492
+ },
869
2493
  });
870
2494
  }
871
2495
  for (const n of nodes.values()) {
@@ -878,6 +2502,7 @@ function layoutSankey(input) {
878
2502
  height: px(n.height),
879
2503
  fill: n.color,
880
2504
  title: `${n.name}: ${formatValue(n.value)}`,
2505
+ item: { label: `${n.name}: ${formatValue(n.value)}`, name: n.name, value: n.value },
881
2506
  });
882
2507
  const last = n.depth === depths;
883
2508
  shapes.push(label(px(last ? n.x - 6 : n.x + NODE_WIDTH + 6), px(n.y + n.height / 2), n.name, {
@@ -916,7 +2541,7 @@ function dropBackEdges(links) {
916
2541
  * This file is part of the SkandaDX organization.
917
2542
  * Licensed under the MIT License. See LICENSE in the repository root.
918
2543
  */
919
- const nodeValue$1 = (n) => n.value ?? (n.children ?? []).reduce((s, c) => s + nodeValue$1(c), 0);
2544
+ const nodeValue$1 = (n) => isFinite(n.value) ? n.value : (n.children ?? []).reduce((s, c) => s + nodeValue$1(c), 0);
920
2545
  /** Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles kept as close to square as possible. */
921
2546
  function squarify(items, x, y, w, h, out) {
922
2547
  if (items.length === 0)
@@ -962,10 +2587,14 @@ function squarify(items, x, y, w, h, out) {
962
2587
  }
963
2588
  }
964
2589
  function layoutTreemap(input) {
2590
+ const state = stateCanvas(input, !input.data.some((n) => nodeValue$1(n) > 0));
2591
+ if (state)
2592
+ return state;
2593
+ const formatValue = makeFormatter(input.format);
965
2594
  const { width, height } = size(input);
966
2595
  const top = legendTop(input);
967
2596
  const shapes = [];
968
- const legend = input.data.map((n, i) => ({ name: n.name, color: seriesColor(i, n.color) }));
2597
+ const legend = input.data.map((n, i) => ({ name: n.name, color: colorOf(input, i, n.color) }));
969
2598
  // Branches are outlined and their children tiled inside a 2px inset; leaves are filled.
970
2599
  const tile = (nodes, x, y, w, h, inherited) => {
971
2600
  const total = nodes.reduce((s, n) => s + Math.max(0, nodeValue$1(n)), 0);
@@ -973,10 +2602,10 @@ function layoutTreemap(input) {
973
2602
  .map((node, i) => ({
974
2603
  node,
975
2604
  value: nodeValue$1(node),
976
- color: node.color ?? inherited ?? seriesColor(i),
2605
+ color: node.color ?? inherited ?? colorOf(input, i),
977
2606
  area: total > 0 ? (Math.max(0, nodeValue$1(node)) / total) * w * h : 0,
978
2607
  }))
979
- .filter((t) => t.area > 0)
2608
+ .filter((t) => t.area > 0 && !(inherited === undefined && isHidden(input, t.node.name)))
980
2609
  .sort((a, b) => b.area - a.area);
981
2610
  squarify(tiles, x, y, w, h, (t, [tx, ty, tw, th]) => {
982
2611
  const title = `${t.node.name}: ${formatValue(t.value)}`;
@@ -992,6 +2621,7 @@ function layoutTreemap(input) {
992
2621
  opacity: branch ? 0.15 : undefined,
993
2622
  stroke: 'var(--skdx-color-surface-default)',
994
2623
  title,
2624
+ item: { label: title, name: t.node.name, value: t.value },
995
2625
  });
996
2626
  if (branch)
997
2627
  tile(t.node.children, tx + 2, ty + 2, Math.max(0, tw - 4), Math.max(0, th - 4), t.color);
@@ -1012,52 +2642,226 @@ function layoutTreemap(input) {
1012
2642
  * This file is part of the SkandaDX organization.
1013
2643
  * Licensed under the MIT License. See LICENSE in the repository root.
1014
2644
  */
1015
- const UP$1 = 'var(--skdx-color-success-500)';
1016
- const DOWN$1 = 'var(--skdx-color-danger-500)';
2645
+ const UP$1 = CHART_COLORS.positive;
2646
+ const DOWN$1 = CHART_COLORS.negative;
2647
+ const VOLUME_SHARE = 0.22;
2648
+ const BRUSH_HEIGHT = 32;
2649
+ const BRUSH_GAP = 28;
2650
+ const valid = (c) => isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close);
2651
+ /** Candles or OHLC bars on a time axis, with an optional volume pane, references, zoom and brush. */
1017
2652
  function layoutCandlestick(input) {
2653
+ const all = input.data.filter(valid);
2654
+ const state = stateCanvas(input, all.length === 0);
2655
+ if (state)
2656
+ return state;
2657
+ const format = makeFormatter(input.format);
2658
+ const count = all.length;
2659
+ const start = Math.max(0, Math.min(count - 2, Math.floor(input.zoom?.start ?? 0)));
2660
+ const end = input.zoom ? Math.max(start + 2, Math.min(count, Math.ceil(input.zoom.end))) : count;
2661
+ const zoom = { start, end };
2662
+ const data = all.slice(start, end);
2663
+ const hasVolume = all.some((c) => isFinite(c.volume));
1018
2664
  const { width, height } = size(input);
1019
- const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
1020
- const y = axis(extent(input.data.flatMap((c) => [c.low, c.high]), false), [plot.y + plot.height, plot.y], input.scale === 'log');
1021
- const x = bandScale(input.data.length, [plot.x, plot.x + plot.width]);
2665
+ const brushRoom = input.brush ? BRUSH_HEIGHT + BRUSH_GAP : 0;
2666
+ const outer = plotBox(width, height, {
2667
+ ...MARGIN$1,
2668
+ top: MARGIN$1.top + legendTop(input),
2669
+ bottom: MARGIN$1.bottom + brushRoom,
2670
+ });
2671
+ const volumeH = hasVolume ? outer.height * VOLUME_SHARE : 0;
2672
+ const plot = { ...outer, height: Math.max(0, outer.height - volumeH - (hasVolume ? 8 : 0)) };
2673
+ const y = axis(extent(data.flatMap((c) => [c.low, c.high]), false), [plot.y + plot.height, plot.y], input.scale === 'log', input.format);
2674
+ const x = bandScale(data.length, [plot.x, plot.x + plot.width]);
2675
+ const labels = data.map((c) => categoryLabel(c.label, input.xAxis?.format));
1022
2676
  const legend = [
1023
2677
  { name: 'Up', color: UP$1 },
1024
2678
  { name: 'Down', color: DOWN$1 },
1025
2679
  ];
1026
- const c = canvas(input, width, height, legend);
2680
+ const c0 = canvas(input, width, height, legend);
1027
2681
  const frame = {
1028
- ...c,
2682
+ ...c0,
1029
2683
  plot,
1030
- xTicks: input.data.map((c, i) => ({ pos: px(x.center(i)), label: c.label })),
2684
+ xTicks: thinTicks(data.map((_, i) => ({ pos: px(x.center(i)), label: labels[i] })), plot.width),
1031
2685
  yTicks: y.ticks,
1032
2686
  baseline: plot.y + plot.height,
1033
2687
  };
1034
2688
  const shapes = frameShapes(frame);
1035
- input.data.forEach((c, i) => {
1036
- const color = c.close >= c.open ? UP$1 : DOWN$1;
1037
- const title = `${c.label}: O ${formatValue(c.open)} H ${formatValue(c.high)} L ${formatValue(c.low)} C ${formatValue(c.close)}`;
2689
+ shapes.push(...referenceShapes(input.references, {
2690
+ plot,
2691
+ x: (v) => {
2692
+ const target = typeof v === 'string' ? v : categoryValue(v);
2693
+ const i = data.findIndex((c, idx) => typeof target === 'string'
2694
+ ? labels[idx] === target || c.label === target
2695
+ : categoryValue(c.label) === target);
2696
+ return i < 0 ? undefined : x.center(i);
2697
+ },
2698
+ y: (v) => y.scale(v),
2699
+ }));
2700
+ const ohlc = input.variant === 'ohlc';
2701
+ const rows = [];
2702
+ data.forEach((c, i) => {
2703
+ const up = c.close >= c.open;
2704
+ const color = up ? UP$1 : DOWN$1;
2705
+ const cx = px(x.center(i));
2706
+ const title = `${labels[i]}: O ${format(c.open)} H ${format(c.high)} L ${format(c.low)} C ${format(c.close)}${isFinite(c.volume) ? ` V ${format(c.volume)}` : ''}`;
2707
+ const item = { label: title, category: labels[i], index: start + i, value: c.close };
2708
+ rows.push([
2709
+ labels[i],
2710
+ `Open: ${format(c.open)}`,
2711
+ `High: ${format(c.high)}`,
2712
+ `Low: ${format(c.low)}`,
2713
+ `Close: ${format(c.close)}`,
2714
+ ...(isFinite(c.volume) ? [`Volume: ${format(c.volume)}`] : []),
2715
+ ]);
1038
2716
  const top = y.scale(Math.max(c.open, c.close));
1039
2717
  const bottom = y.scale(Math.min(c.open, c.close));
2718
+ if (ohlc) {
2719
+ const half = x.bandwidth / 2;
2720
+ shapes.push({
2721
+ kind: 'line',
2722
+ part: 'wick',
2723
+ x1: cx,
2724
+ x2: cx,
2725
+ y1: px(y.scale(c.high)),
2726
+ y2: px(y.scale(c.low)),
2727
+ stroke: color,
2728
+ strokeWidth: 1.5,
2729
+ title,
2730
+ item,
2731
+ }, {
2732
+ kind: 'line',
2733
+ part: 'open',
2734
+ x1: px(cx - half),
2735
+ x2: cx,
2736
+ y1: px(y.scale(c.open)),
2737
+ y2: px(y.scale(c.open)),
2738
+ stroke: color,
2739
+ strokeWidth: 1.5,
2740
+ }, {
2741
+ kind: 'line',
2742
+ part: 'close',
2743
+ x1: cx,
2744
+ x2: px(cx + half),
2745
+ y1: px(y.scale(c.close)),
2746
+ y2: px(y.scale(c.close)),
2747
+ stroke: color,
2748
+ strokeWidth: 1.5,
2749
+ });
2750
+ }
2751
+ else {
2752
+ shapes.push({
2753
+ kind: 'line',
2754
+ part: 'wick',
2755
+ x1: cx,
2756
+ x2: cx,
2757
+ y1: px(y.scale(c.high)),
2758
+ y2: px(y.scale(c.low)),
2759
+ stroke: color,
2760
+ title,
2761
+ }, {
2762
+ kind: 'rect',
2763
+ part: 'candle',
2764
+ x: px(x.start(i)),
2765
+ y: px(top),
2766
+ width: px(x.bandwidth),
2767
+ height: px(Math.max(1, bottom - top)),
2768
+ fill: color,
2769
+ title,
2770
+ item,
2771
+ });
2772
+ }
2773
+ });
2774
+ if (hasVolume) {
2775
+ const vTop = plot.y + plot.height + 8;
2776
+ const [, vMax] = minMax(data.map((c) => c.volume));
2777
+ data.forEach((c, i) => {
2778
+ if (!isFinite(c.volume))
2779
+ return;
2780
+ const h = vMax > 0 ? (c.volume / vMax) * volumeH : 0;
2781
+ shapes.push({
2782
+ kind: 'rect',
2783
+ part: 'volume',
2784
+ x: px(x.start(i)),
2785
+ y: px(vTop + volumeH - h),
2786
+ width: px(x.bandwidth),
2787
+ height: px(Math.max(h, 0)),
2788
+ fill: c.close >= c.open ? UP$1 : DOWN$1,
2789
+ opacity: 0.4,
2790
+ title: `${labels[i]}: volume ${format(c.volume)}`,
2791
+ });
2792
+ });
2793
+ }
2794
+ const hit = {
2795
+ plot: outer,
2796
+ xs: data.map((_, i) => px(x.center(i))),
2797
+ rows,
2798
+ count,
2799
+ zoom,
2800
+ crosshair: input.crosshair ?? false,
2801
+ };
2802
+ if (input.brush) {
2803
+ const box = {
2804
+ x: outer.x,
2805
+ y: outer.y + outer.height + BRUSH_GAP,
2806
+ width: outer.width,
2807
+ height: BRUSH_HEIGHT,
2808
+ };
2809
+ const closes = all.map((c) => c.close);
2810
+ const [lo, hi] = minMax(closes);
2811
+ const step = count > 1 ? box.width / (count - 1) : 0;
2812
+ const pts = closes.map((v, i) => ({
2813
+ x: px(box.x + i * step),
2814
+ y: px(box.y + box.height - (hi === lo ? 0.5 : (v - lo) / (hi - lo)) * (box.height - 4) - 2),
2815
+ }));
2816
+ const window = {
2817
+ x: px(box.x + (start / count) * box.width),
2818
+ y: box.y,
2819
+ width: px(((end - start) / count) * box.width),
2820
+ height: box.height,
2821
+ };
1040
2822
  shapes.push({
1041
- kind: 'line',
1042
- part: 'wick',
1043
- x1: px(x.center(i)),
1044
- x2: px(x.center(i)),
1045
- y1: px(y.scale(c.high)),
1046
- y2: px(y.scale(c.low)),
1047
- stroke: color,
1048
- title,
2823
+ kind: 'rect',
2824
+ part: 'brush',
2825
+ ...box,
2826
+ fill: 'var(--skdx-color-surface-sunken)',
2827
+ stroke: CHART_COLORS.grid,
2828
+ }, {
2829
+ kind: 'path',
2830
+ part: 'brush-series',
2831
+ d: pts.length > 1
2832
+ ? `${pathThrough(pts)}L${pts[pts.length - 1]?.x} ${box.y + box.height}L${pts[0]?.x} ${box.y + box.height}Z`
2833
+ : '',
2834
+ fill: 'var(--skdx-color-primary-500)',
2835
+ fillOpacity: 0.3,
1049
2836
  }, {
1050
2837
  kind: 'rect',
1051
- part: 'candle',
1052
- x: px(x.start(i)),
1053
- y: px(top),
1054
- width: px(x.bandwidth),
1055
- height: px(Math.max(1, bottom - top)),
1056
- fill: color,
1057
- title,
2838
+ part: 'brush-window',
2839
+ ...window,
2840
+ fill: 'var(--skdx-color-primary-500)',
2841
+ opacity: 0.15,
2842
+ stroke: CHART_COLORS.axis,
2843
+ }, {
2844
+ kind: 'rect',
2845
+ part: 'brush-handle',
2846
+ x: px(window.x - 3),
2847
+ y: box.y + 8,
2848
+ width: 6,
2849
+ height: box.height - 16,
2850
+ fill: CHART_COLORS.axis,
2851
+ rx: 2,
2852
+ }, {
2853
+ kind: 'rect',
2854
+ part: 'brush-handle',
2855
+ x: px(window.x + window.width - 3),
2856
+ y: box.y + 8,
2857
+ width: 6,
2858
+ height: box.height - 16,
2859
+ fill: CHART_COLORS.axis,
2860
+ rx: 2,
1058
2861
  });
1059
- });
1060
- return { ...c, shapes: [...shapes, ...c.shapes] };
2862
+ hit.brush = { box, window };
2863
+ }
2864
+ return { ...c0, shapes: [...shapes, ...c0.shapes], hit };
1061
2865
  }
1062
2866
 
1063
2867
  /*
@@ -1068,11 +2872,16 @@ function layoutCandlestick(input) {
1068
2872
  const UP = 'var(--skdx-color-success-500)';
1069
2873
  const DOWN = 'var(--skdx-color-danger-500)';
1070
2874
  function layoutWaterfall(input) {
2875
+ const data = input.data.filter((d) => isFinite(d.value));
2876
+ const state = stateCanvas(input, data.length === 0);
2877
+ if (state)
2878
+ return state;
2879
+ const formatValue = makeFormatter(input.format);
1071
2880
  const { width, height } = size(input);
1072
2881
  const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
1073
2882
  const steps = [];
1074
2883
  let running = 0;
1075
- for (const d of input.data) {
2884
+ for (const d of data) {
1076
2885
  const from = running;
1077
2886
  running += d.value;
1078
2887
  steps.push({
@@ -1088,11 +2897,11 @@ function layoutWaterfall(input) {
1088
2897
  name: input.total,
1089
2898
  from: 0,
1090
2899
  to: running,
1091
- color: seriesColor(0),
2900
+ color: colorOf(input, 0),
1092
2901
  title: `${input.total}: ${formatValue(running)}`,
1093
2902
  });
1094
2903
  const totals = steps.flatMap((s) => [s.from, s.to]);
1095
- const y = axis([Math.min(0, ...totals), Math.max(0, ...totals)], [plot.y + plot.height, plot.y]);
2904
+ const y = axis([Math.min(0, ...totals), Math.max(0, ...totals)], [plot.y + plot.height, plot.y], {}, input.format);
1096
2905
  const x = bandScale(steps.length, [plot.x, plot.x + plot.width]);
1097
2906
  const c = canvas(input, width, height, [
1098
2907
  { name: 'Increase', color: UP },
@@ -1101,7 +2910,7 @@ function layoutWaterfall(input) {
1101
2910
  const shapes = frameShapes({
1102
2911
  ...c,
1103
2912
  plot,
1104
- xTicks: steps.map((s, i) => ({ pos: px(x.center(i)), label: s.name })),
2913
+ xTicks: thinTicks(steps.map((s, i) => ({ pos: px(x.center(i)), label: s.name })), plot.width),
1105
2914
  yTicks: y.ticks,
1106
2915
  baseline: y.zero,
1107
2916
  });
@@ -1117,6 +2926,7 @@ function layoutWaterfall(input) {
1117
2926
  height: px(Math.max(1, Math.abs(y1 - y0))),
1118
2927
  fill: s.color,
1119
2928
  title: s.title,
2929
+ item: { label: s.title, name: s.name, index: i, value: s.to - s.from },
1120
2930
  });
1121
2931
  const next = steps[i + 1];
1122
2932
  if (next)
@@ -1139,24 +2949,30 @@ function layoutWaterfall(input) {
1139
2949
  * This file is part of the SkandaDX organization.
1140
2950
  * Licensed under the MIT License. See LICENSE in the repository root.
1141
2951
  */
2952
+ /**
2953
+ * Intervals on a numeric time axis, one row per task, or per `group` when tasks share one.
2954
+ * Generic enough for schedules, on-call shifts, uptime windows and market sessions.
2955
+ */
1142
2956
  function layoutGantt(input) {
2957
+ const tasks = input.tasks.filter((t) => isFinite(t.start) && isFinite(t.end));
2958
+ const state = stateCanvas(input, tasks.length === 0);
2959
+ if (state)
2960
+ return state;
1143
2961
  const { width, height } = size(input);
2962
+ const rowNames = [...new Set(tasks.map((t) => t.group ?? t.name))];
2963
+ const widest = Math.max(0, ...rowNames.map((n) => n.length)) * CHAR;
1144
2964
  const plot = plotBox(width, height, {
1145
2965
  top: 8 + legendTop(input),
1146
2966
  right: 16,
1147
2967
  bottom: 32,
1148
- left: 112,
2968
+ left: Math.min(width / 2, Math.max(64, widest + 16)),
1149
2969
  });
1150
- const format = input.format ?? formatValue;
1151
- const times = input.tasks.flatMap((t) => [t.start, t.end]);
1152
- const x = axis(times.length ? [Math.min(...times), Math.max(...times)] : [0, 0], [
1153
- plot.x,
1154
- plot.x + plot.width,
1155
- ]);
1156
- const rows = bandScale(input.tasks.length, [plot.y, plot.y + plot.height]);
2970
+ const format = makeFormatter(input.format);
2971
+ const [lo, hi] = minMax(tasks.flatMap((t) => [t.start, t.end]));
2972
+ const x = axis([lo, hi], [plot.x, plot.x + plot.width], {}, input.format);
2973
+ const rows = bandScale(rowNames.length, [plot.y, plot.y + plot.height]);
1157
2974
  const shapes = [];
1158
- const ticks = x.ticks.map((t) => ({ ...t, label: format(Number(t.label.replace(/,/g, ''))) }));
1159
- for (const t of ticks) {
2975
+ for (const t of thinTicks(x.ticks, plot.width)) {
1160
2976
  shapes.push({
1161
2977
  kind: 'line',
1162
2978
  part: 'grid',
@@ -1168,26 +2984,37 @@ function layoutGantt(input) {
1168
2984
  });
1169
2985
  shapes.push(label(t.pos, plot.y + plot.height + 20, t.label, { part: 'x-label', anchor: 'middle' }));
1170
2986
  }
1171
- input.tasks.forEach((t, i) => {
2987
+ rowNames.forEach((name, i) => shapes.push(label(plot.x - 8, px(rows.center(i)), name, {
2988
+ part: 'y-label',
2989
+ anchor: 'end',
2990
+ baseline: 'middle',
2991
+ })));
2992
+ shapes.push(...referenceShapes(input.references, {
2993
+ plot,
2994
+ x: (v) => x.scale(categoryValue(v)),
2995
+ y: (v) => rows.center(v),
2996
+ }));
2997
+ tasks.forEach((t, i) => {
2998
+ if (isHidden(input, t.name))
2999
+ return;
3000
+ const row = rowNames.indexOf(t.group ?? t.name);
1172
3001
  const x0 = x.scale(Math.min(t.start, t.end));
1173
3002
  const x1 = x.scale(Math.max(t.start, t.end));
1174
- shapes.push(label(plot.x - 8, px(rows.center(i)), t.name, {
1175
- part: 'y-label',
1176
- anchor: 'end',
1177
- baseline: 'middle',
1178
- }));
3003
+ const title = `${t.name}: ${format(t.start)} – ${format(t.end)}`;
1179
3004
  shapes.push({
1180
3005
  kind: 'rect',
1181
3006
  part: 'bar',
1182
3007
  x: px(x0),
1183
- y: px(rows.start(i)),
3008
+ y: px(rows.start(row)),
1184
3009
  width: px(Math.max(1, x1 - x0)),
1185
3010
  height: px(rows.bandwidth),
1186
- fill: seriesColor(i, t.color),
1187
- title: `${t.name}: ${format(t.start)} – ${format(t.end)}`,
3011
+ fill: colorOf(input, i, t.color),
3012
+ rx: 2,
3013
+ title,
3014
+ item: { label: title, name: t.name, category: t.group, index: i, value: t.end - t.start },
1188
3015
  });
1189
3016
  });
1190
- const c = canvas(input, width, height, input.tasks.map((t, i) => ({ name: t.name, color: seriesColor(i, t.color) })));
3017
+ const c = canvas(input, width, height, input.tasks.map((t, i) => ({ name: t.name, color: colorOf(input, i, t.color) })));
1191
3018
  return { ...c, shapes: [...shapes, ...c.shapes] };
1192
3019
  }
1193
3020
 
@@ -1209,7 +3036,7 @@ const quantile = (sorted, q) => {
1209
3036
  };
1210
3037
  /** Five-number summary with Tukey fences: whiskers reach the last sample within 1.5 IQR. */
1211
3038
  function boxStats(data) {
1212
- const sorted = [...data].sort((a, b) => a - b);
3039
+ const sorted = finite(data).sort((a, b) => a - b);
1213
3040
  const q1 = quantile(sorted, 0.25);
1214
3041
  const median = quantile(sorted, 0.5);
1215
3042
  const q3 = quantile(sorted, 0.75);
@@ -1225,21 +3052,27 @@ function boxStats(data) {
1225
3052
  };
1226
3053
  }
1227
3054
  function layoutBoxplot(input) {
3055
+ const state = stateCanvas(input, input.series.every((s) => finite(s.data).length === 0));
3056
+ if (state)
3057
+ return state;
3058
+ const formatValue = makeFormatter(input.format);
1228
3059
  const { width, height } = size(input);
1229
3060
  const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
1230
- const y = axis(extent(input.series.flatMap((s) => s.data), false), [plot.y + plot.height, plot.y]);
3061
+ const y = axis(extent(input.series.flatMap((s) => (isHidden(input, s.name) ? [] : s.data)), false), [plot.y + plot.height, plot.y], {}, input.format);
1231
3062
  const x = bandScale(input.series.length, [plot.x, plot.x + plot.width]);
1232
- const c = canvas(input, width, height, input.series.map((s, i) => ({ name: s.name, color: seriesColor(i, s.color) })));
3063
+ const c = canvas(input, width, height, input.series.map((s, i) => ({ name: s.name, color: colorOf(input, i, s.color) })));
1233
3064
  const shapes = frameShapes({
1234
3065
  ...c,
1235
3066
  plot,
1236
- xTicks: input.series.map((s, i) => ({ pos: px(x.center(i)), label: s.name })),
3067
+ xTicks: thinTicks(input.series.map((s, i) => ({ pos: px(x.center(i)), label: s.name })), plot.width),
1237
3068
  yTicks: y.ticks,
1238
3069
  baseline: plot.y + plot.height,
1239
3070
  });
1240
3071
  input.series.forEach((s, i) => {
3072
+ if (isHidden(input, s.name) || finite(s.data).length === 0)
3073
+ return;
1241
3074
  const st = boxStats(s.data);
1242
- const color = seriesColor(i, s.color);
3075
+ const color = colorOf(input, i, s.color);
1243
3076
  const cx = px(x.center(i));
1244
3077
  const title = `${s.name}: min ${formatValue(st.low)} Q1 ${formatValue(st.q1)} median ${formatValue(st.median)} Q3 ${formatValue(st.q3)} max ${formatValue(st.high)}`;
1245
3078
  const boxW = x.bandwidth * 0.6;
@@ -1264,6 +3097,7 @@ function layoutBoxplot(input) {
1264
3097
  stroke: color,
1265
3098
  opacity: 0.5,
1266
3099
  title,
3100
+ item: { label: title, series: s.name, index: i, value: st.median },
1267
3101
  }, {
1268
3102
  kind: 'line',
1269
3103
  part: 'median',
@@ -1292,9 +3126,13 @@ function layoutBoxplot(input) {
1292
3126
  * Licensed under the MIT License. See LICENSE in the repository root.
1293
3127
  */
1294
3128
  const TAU$1 = 2 * Math.PI;
1295
- const nodeValue = (n) => n.value ?? (n.children ?? []).reduce((s, c) => s + nodeValue(c), 0);
3129
+ const nodeValue = (n) => isFinite(n.value) ? n.value : (n.children ?? []).reduce((s, c) => s + nodeValue(c), 0);
1296
3130
  const depthOf = (nodes) => nodes.length === 0 ? 0 : 1 + Math.max(...nodes.map((n) => depthOf(n.children ?? [])));
1297
3131
  function layoutSunburst(input) {
3132
+ const state = stateCanvas(input, !input.data.some((n) => nodeValue(n) > 0), 300, 300);
3133
+ if (state)
3134
+ return state;
3135
+ const formatValue = makeFormatter(input.format);
1298
3136
  const { width, height } = size(input, 300, 300);
1299
3137
  const top = legendTop(input);
1300
3138
  const cx = width / 2;
@@ -1305,9 +3143,9 @@ function layoutSunburst(input) {
1305
3143
  const total = nodes.reduce((s, n) => s + Math.max(0, nodeValue(n)), 0);
1306
3144
  let angle = a0;
1307
3145
  nodes.forEach((n, i) => {
1308
- const value = Math.max(0, nodeValue(n));
3146
+ const value = depth === 0 && isHidden(input, n.name) ? 0 : Math.max(0, nodeValue(n));
1309
3147
  const sweep = total > 0 ? (value / total) * span : 0;
1310
- const fill = n.color ?? color ?? seriesColor(i);
3148
+ const fill = n.color ?? color ?? colorOf(input, i);
1311
3149
  if (sweep > 0) {
1312
3150
  shapes.push({
1313
3151
  kind: 'path',
@@ -1317,6 +3155,7 @@ function layoutSunburst(input) {
1317
3155
  opacity: 1 - depth * 0.2,
1318
3156
  stroke: 'var(--skdx-color-surface-default)',
1319
3157
  title: `${n.name}: ${formatValue(value)}`,
3158
+ item: { label: `${n.name}: ${formatValue(value)}`, name: n.name, value },
1320
3159
  });
1321
3160
  walk(n.children ?? [], depth + 1, angle, sweep, fill);
1322
3161
  }
@@ -1324,7 +3163,7 @@ function layoutSunburst(input) {
1324
3163
  });
1325
3164
  };
1326
3165
  walk(input.data, 0, 0, TAU$1, undefined);
1327
- const c = canvas(input, width, height, input.data.map((n, i) => ({ name: n.name, color: n.color ?? seriesColor(i) })));
3166
+ const c = canvas(input, width, height, input.data.map((n, i) => ({ name: n.name, color: colorOf(input, i, n.color) })));
1328
3167
  return { ...c, shapes: [...shapes, ...c.shapes] };
1329
3168
  }
1330
3169
 
@@ -1337,6 +3176,10 @@ const TAU = 2 * Math.PI;
1337
3176
  const PAD$1 = 0.04;
1338
3177
  const RING = 12;
1339
3178
  function layoutChord(input) {
3179
+ const state = stateCanvas(input, input.names.length === 0 || !input.matrix.some((row) => row.some((v) => isFinite(v) && v > 0)), 300, 300);
3180
+ if (state)
3181
+ return state;
3182
+ const formatValue = makeFormatter(input.format);
1340
3183
  const { width, height } = size(input, 300, 300);
1341
3184
  const top = legendTop(input);
1342
3185
  const cx = width / 2;
@@ -1344,7 +3187,11 @@ function layoutChord(input) {
1344
3187
  const r = Math.max(0, Math.min(width, height - top) / 2 - 36);
1345
3188
  const r0 = Math.max(0, r - RING);
1346
3189
  const n = input.names.length;
1347
- const cell = (i, j) => Math.max(0, input.matrix[i]?.[j] ?? 0);
3190
+ const cell = (i, j) => {
3191
+ const v = input.matrix[i]?.[j];
3192
+ const hidden = isHidden(input, input.names[i]) || isHidden(input, input.names[j]);
3193
+ return hidden || !isFinite(v) ? 0 : Math.max(0, v);
3194
+ };
1348
3195
  const rowSum = input.names.map((_, i) => input.names.reduce((s, _n, j) => s + cell(i, j), 0));
1349
3196
  const total = rowSum.reduce((s, v) => s + v, 0);
1350
3197
  const unit = total > 0 ? (TAU - PAD$1 * n) / total : 0;
@@ -1380,7 +3227,7 @@ function layoutChord(input) {
1380
3227
  kind: 'path',
1381
3228
  part: 'chord',
1382
3229
  d,
1383
- fill: seriesColor(from),
3230
+ fill: colorOf(input, from),
1384
3231
  opacity: 0.5,
1385
3232
  title: i === j
1386
3233
  ? `${input.names[i]}: ${formatValue(out)}`
@@ -1395,8 +3242,14 @@ function layoutChord(input) {
1395
3242
  kind: 'path',
1396
3243
  part: 'group',
1397
3244
  d: arc(cx, cy, r, r0, g.a0, g.a1),
1398
- fill: seriesColor(i),
3245
+ fill: colorOf(input, i),
1399
3246
  title: `${input.names[i]}: ${formatValue(rowSum[i])}`,
3247
+ item: {
3248
+ label: `${input.names[i]}: ${formatValue(rowSum[i])}`,
3249
+ name: input.names[i],
3250
+ index: i,
3251
+ value: rowSum[i],
3252
+ },
1400
3253
  });
1401
3254
  const mid = (g.a0 + g.a1) / 2;
1402
3255
  const sin = Math.sin(mid);
@@ -1405,7 +3258,7 @@ function layoutChord(input) {
1405
3258
  baseline: 'middle',
1406
3259
  }));
1407
3260
  });
1408
- const c = canvas(input, width, height, input.names.map((name, i) => ({ name, color: seriesColor(i) })));
3261
+ const c = canvas(input, width, height, input.names.map((name, i) => ({ name, color: colorOf(input, i) })));
1409
3262
  return { ...c, shapes: [...shapes, ...c.shapes] };
1410
3263
  }
1411
3264
 
@@ -1420,23 +3273,22 @@ const rings = (f) => f.geometry.type === 'Polygon' ? f.geometry.coordinates : f.
1420
3273
  const mercatorY = (lat) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360));
1421
3274
  /** Projection fitted to the features' bounds (equirectangular by default, or Mercator); opacity follows the value. */
1422
3275
  function layoutMap(input) {
3276
+ const state = stateCanvas(input, input.features.length === 0);
3277
+ if (state)
3278
+ return state;
3279
+ const formatValue = makeFormatter(input.format);
1423
3280
  const { width, height } = size(input);
1424
3281
  const top = legendTop(input);
1425
3282
  const toY = input.projection === 'mercator' ? mercatorY : (lat) => lat;
1426
3283
  const points = input.features.flatMap((f) => rings(f).flat());
1427
- const lons = points.map((p) => p[0]);
1428
- const lats = points.map((p) => toY(p[1]));
1429
- const [minLon, maxLon, minLat, maxLat] = points.length
1430
- ? [Math.min(...lons), Math.max(...lons), Math.min(...lats), Math.max(...lats)]
1431
- : [0, 1, 0, 1];
3284
+ const [minLon, maxLon] = points.length ? minMax(points.map((p) => p[0])) : [0, 1];
3285
+ const [minLat, maxLat] = points.length ? minMax(points.map((p) => toY(p[1]))) : [0, 1];
1432
3286
  const scale = Math.min((width - 2 * PAD) / Math.max(1e-9, maxLon - minLon), (height - top - 2 * PAD) / Math.max(1e-9, maxLat - minLat));
1433
3287
  const ox = (width - (maxLon - minLon) * scale) / 2;
1434
3288
  const oy = top + (height - top - (maxLat - minLat) * scale) / 2;
1435
3289
  const project = ([lon, lat]) => `${px(ox + (lon - minLon) * scale)} ${px(oy + (maxLat - toY(lat)) * scale)}`;
1436
- const values = input.features.map((f) => f.value);
1437
- const min = Math.min(...values);
1438
- const max = Math.max(...values);
1439
- const color = seriesColor(0, input.color);
3290
+ const [min, max] = minMax(input.features.map((f) => f.value));
3291
+ const color = colorOf(input, 0, input.color);
1440
3292
  const shapes = input.features.map((f) => ({
1441
3293
  kind: 'path',
1442
3294
  part: 'region',
@@ -1444,9 +3296,12 @@ function layoutMap(input) {
1444
3296
  .map((ring) => `M${ring.map(project).join('L')}Z`)
1445
3297
  .join(''),
1446
3298
  fill: color,
1447
- opacity: max === min ? 1 : px(0.15 + (0.85 * (f.value - min)) / (max - min)),
3299
+ opacity: max === min || !Number.isFinite(f.value)
3300
+ ? 1
3301
+ : px(0.15 + (0.85 * (f.value - min)) / (max - min)),
1448
3302
  stroke: 'var(--skdx-color-surface-default)',
1449
3303
  title: `${f.name}: ${formatValue(f.value)}`,
3304
+ item: { label: `${f.name}: ${formatValue(f.value)}`, name: f.name, value: f.value },
1450
3305
  }));
1451
3306
  return { ...canvas(input, width, height), shapes };
1452
3307
  }
@@ -1462,7 +3317,7 @@ function layoutMap(input) {
1462
3317
  * This file is part of the SkandaDX organization.
1463
3318
  * Licensed under the MIT License. See LICENSE in the repository root.
1464
3319
  */
1465
- /** Draws a core shape list back to front. */
3320
+ /** Draws a core shape list back to front; marks with an `item` carry their index for hit-testing. */
1466
3321
  class SkdxChartShapesComponent {
1467
3322
  constructor() {
1468
3323
  this.shapes = input.required(/* @ts-ignore */
@@ -1475,12 +3330,14 @@ class SkdxChartShapesComponent {
1475
3330
  @case ('path') {
1476
3331
  <svg:path
1477
3332
  [attr.data-skdx-part]="s.part"
3333
+ [attr.data-skdx-i]="s.item ? $index : null"
1478
3334
  [attr.d]="s.d"
1479
3335
  [attr.fill]="s.fill ?? 'none'"
1480
3336
  [attr.stroke]="s.stroke"
1481
3337
  [attr.stroke-width]="s.strokeWidth"
1482
3338
  [attr.stroke-linecap]="s.strokeLinecap"
1483
3339
  [attr.stroke-linejoin]="s.strokeLinejoin"
3340
+ [attr.stroke-dasharray]="s.dash"
1484
3341
  [attr.opacity]="s.opacity"
1485
3342
  [attr.fill-opacity]="s.fillOpacity"
1486
3343
  >
@@ -1492,10 +3349,12 @@ class SkdxChartShapesComponent {
1492
3349
  @case ('rect') {
1493
3350
  <svg:rect
1494
3351
  [attr.data-skdx-part]="s.part"
3352
+ [attr.data-skdx-i]="s.item ? $index : null"
1495
3353
  [attr.x]="s.x"
1496
3354
  [attr.y]="s.y"
1497
3355
  [attr.width]="s.width"
1498
3356
  [attr.height]="s.height"
3357
+ [attr.rx]="s.rx"
1499
3358
  [attr.fill]="s.fill"
1500
3359
  [attr.stroke]="s.stroke"
1501
3360
  [attr.fill-opacity]="s.opacity"
@@ -1508,11 +3367,13 @@ class SkdxChartShapesComponent {
1508
3367
  @case ('line') {
1509
3368
  <svg:line
1510
3369
  [attr.data-skdx-part]="s.part"
3370
+ [attr.data-skdx-i]="s.item ? $index : null"
1511
3371
  [attr.x1]="s.x1"
1512
3372
  [attr.y1]="s.y1"
1513
3373
  [attr.x2]="s.x2"
1514
3374
  [attr.y2]="s.y2"
1515
3375
  [attr.stroke]="s.stroke"
3376
+ [attr.stroke-width]="s.strokeWidth"
1516
3377
  [attr.stroke-dasharray]="s.dash"
1517
3378
  >
1518
3379
  @if (s.title) {
@@ -1523,10 +3384,13 @@ class SkdxChartShapesComponent {
1523
3384
  @case ('circle') {
1524
3385
  <svg:circle
1525
3386
  [attr.data-skdx-part]="s.part"
3387
+ [attr.data-skdx-i]="s.item ? $index : null"
1526
3388
  [attr.cx]="s.cx"
1527
3389
  [attr.cy]="s.cy"
1528
3390
  [attr.r]="s.r"
1529
3391
  [attr.fill]="s.fill"
3392
+ [attr.stroke]="s.stroke"
3393
+ [attr.opacity]="s.opacity"
1530
3394
  >
1531
3395
  @if (s.title) {
1532
3396
  <svg:title>{{ s.title }}</svg:title>
@@ -1536,14 +3400,15 @@ class SkdxChartShapesComponent {
1536
3400
  @case ('text') {
1537
3401
  <svg:text
1538
3402
  [attr.data-skdx-part]="s.part"
3403
+ [attr.data-skdx-i]="s.item ? $index : null"
1539
3404
  [attr.x]="s.x"
1540
3405
  [attr.y]="s.y"
1541
3406
  [attr.text-anchor]="s.anchor"
1542
3407
  [attr.dominant-baseline]="s.baseline"
1543
3408
  [attr.fill]="s.fill"
1544
- >
1545
- {{ s.text }}
1546
- </svg:text>
3409
+ [attr.font-weight]="s.weight"
3410
+ [textContent]="s.text"
3411
+ ></svg:text>
1547
3412
  }
1548
3413
  }
1549
3414
  }
@@ -1561,12 +3426,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1561
3426
  @case ('path') {
1562
3427
  <svg:path
1563
3428
  [attr.data-skdx-part]="s.part"
3429
+ [attr.data-skdx-i]="s.item ? $index : null"
1564
3430
  [attr.d]="s.d"
1565
3431
  [attr.fill]="s.fill ?? 'none'"
1566
3432
  [attr.stroke]="s.stroke"
1567
3433
  [attr.stroke-width]="s.strokeWidth"
1568
3434
  [attr.stroke-linecap]="s.strokeLinecap"
1569
3435
  [attr.stroke-linejoin]="s.strokeLinejoin"
3436
+ [attr.stroke-dasharray]="s.dash"
1570
3437
  [attr.opacity]="s.opacity"
1571
3438
  [attr.fill-opacity]="s.fillOpacity"
1572
3439
  >
@@ -1578,10 +3445,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1578
3445
  @case ('rect') {
1579
3446
  <svg:rect
1580
3447
  [attr.data-skdx-part]="s.part"
3448
+ [attr.data-skdx-i]="s.item ? $index : null"
1581
3449
  [attr.x]="s.x"
1582
3450
  [attr.y]="s.y"
1583
3451
  [attr.width]="s.width"
1584
3452
  [attr.height]="s.height"
3453
+ [attr.rx]="s.rx"
1585
3454
  [attr.fill]="s.fill"
1586
3455
  [attr.stroke]="s.stroke"
1587
3456
  [attr.fill-opacity]="s.opacity"
@@ -1594,11 +3463,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1594
3463
  @case ('line') {
1595
3464
  <svg:line
1596
3465
  [attr.data-skdx-part]="s.part"
3466
+ [attr.data-skdx-i]="s.item ? $index : null"
1597
3467
  [attr.x1]="s.x1"
1598
3468
  [attr.y1]="s.y1"
1599
3469
  [attr.x2]="s.x2"
1600
3470
  [attr.y2]="s.y2"
1601
3471
  [attr.stroke]="s.stroke"
3472
+ [attr.stroke-width]="s.strokeWidth"
1602
3473
  [attr.stroke-dasharray]="s.dash"
1603
3474
  >
1604
3475
  @if (s.title) {
@@ -1609,10 +3480,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1609
3480
  @case ('circle') {
1610
3481
  <svg:circle
1611
3482
  [attr.data-skdx-part]="s.part"
3483
+ [attr.data-skdx-i]="s.item ? $index : null"
1612
3484
  [attr.cx]="s.cx"
1613
3485
  [attr.cy]="s.cy"
1614
3486
  [attr.r]="s.r"
1615
3487
  [attr.fill]="s.fill"
3488
+ [attr.stroke]="s.stroke"
3489
+ [attr.opacity]="s.opacity"
1616
3490
  >
1617
3491
  @if (s.title) {
1618
3492
  <svg:title>{{ s.title }}</svg:title>
@@ -1622,14 +3496,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1622
3496
  @case ('text') {
1623
3497
  <svg:text
1624
3498
  [attr.data-skdx-part]="s.part"
3499
+ [attr.data-skdx-i]="s.item ? $index : null"
1625
3500
  [attr.x]="s.x"
1626
3501
  [attr.y]="s.y"
1627
3502
  [attr.text-anchor]="s.anchor"
1628
3503
  [attr.dominant-baseline]="s.baseline"
1629
3504
  [attr.fill]="s.fill"
1630
- >
1631
- {{ s.text }}
1632
- </svg:text>
3505
+ [attr.font-weight]="s.weight"
3506
+ [textContent]="s.text"
3507
+ ></svg:text>
1633
3508
  }
1634
3509
  }
1635
3510
  }
@@ -1642,7 +3517,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1642
3517
  * This file is part of the SkandaDX organization.
1643
3518
  * Licensed under the MIT License. See LICENSE in the repository root.
1644
3519
  */
1645
- /** The accessible `<svg>` root every chart renders into: projected marks, then decorations, then the hover tooltip. */
3520
+ /**
3521
+ * The accessible `<svg>` root every chart renders into: marks, decorations, then the hover or
3522
+ * keyboard tooltip. Owns focus state; reports legend toggles and zoom changes to the chart.
3523
+ */
1646
3524
  class SkdxChartRootComponent {
1647
3525
  constructor() {
1648
3526
  this.layout = input.required(/* @ts-ignore */
@@ -1651,37 +3529,219 @@ class SkdxChartRootComponent {
1651
3529
  ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
1652
3530
  this.label = input.required(/* @ts-ignore */
1653
3531
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
3532
+ /** Wheel and pinch zoom, drag pans the category axis. */
3533
+ this.zoomable = input(false, /* @ts-ignore */
3534
+ ...(ngDevMode ? [{ debugName: "zoomable" }] : /* istanbul ignore next */ []));
3535
+ /** Measure the host and report its width, so the chart can lay out at that width. */
3536
+ this.responsive = input(false, /* @ts-ignore */
3537
+ ...(ngDevMode ? [{ debugName: "responsive" }] : /* istanbul ignore next */ []));
3538
+ /** A mark was clicked, or Enter/Space was pressed on the focused mark. */
3539
+ this.itemClick = output();
3540
+ /** The legend toggled a series; the chart applies it through its `hiddenSeries` model. */
3541
+ this.hiddenSeriesChange = output();
3542
+ /** Wheel, drag, pinch or brush moved the window; the chart applies it through its `zoom` model. */
3543
+ this.zoomChange = output();
3544
+ /** The host's measured width, under `responsive`. */
3545
+ this.measured = output();
1654
3546
  this.colors = CHART_COLORS;
3547
+ this.duration = computed(() => animationDuration(this.layout()) ?? null, /* @ts-ignore */
3548
+ ...(ngDevMode ? [{ debugName: "duration" }] : /* istanbul ignore next */ []));
3549
+ this.pointers = new Map();
3550
+ this.svg = viewChild.required('svg', /* @ts-ignore */
3551
+ ...(ngDevMode ? [{ debugName: "svg" }] : /* istanbul ignore next */ []));
1655
3552
  this.tip = signal([], /* @ts-ignore */
1656
3553
  ...(ngDevMode ? [{ debugName: "tip" }] : /* istanbul ignore next */ []));
3554
+ this.focused = signal(null, /* @ts-ignore */
3555
+ ...(ngDevMode ? [{ debugName: "focused" }] : /* istanbul ignore next */ []));
3556
+ this.canZoom = computed(() => {
3557
+ const hit = this.layout().hit;
3558
+ return Boolean(hit && (this.zoomable() || hit.brush));
3559
+ }, /* @ts-ignore */
3560
+ ...(ngDevMode ? [{ debugName: "canZoom" }] : /* istanbul ignore next */ []));
3561
+ this.focusable = computed(() => !this.layout().decorative && focusTargets(this.layout()).length > 0, /* @ts-ignore */
3562
+ ...(ngDevMode ? [{ debugName: "focusable" }] : /* istanbul ignore next */ []));
3563
+ this.descId = computed(() => this.layout().description ? `${this.kind()}-desc-${this.label().replace(/\W+/g, '-')}` : null, /* @ts-ignore */
3564
+ ...(ngDevMode ? [{ debugName: "descId" }] : /* istanbul ignore next */ []));
3565
+ this.focusShapes = computed(() => focusShapes(this.layout(), this.focused()), /* @ts-ignore */
3566
+ ...(ngDevMode ? [{ debugName: "focusShapes" }] : /* istanbul ignore next */ []));
3567
+ this.drag = null;
3568
+ const host = inject(ElementRef).nativeElement;
3569
+ const destroy = inject(DestroyRef);
3570
+ afterNextRender(() => {
3571
+ // Angular strips `<style>` from templates and cannot resolve imported `styles` statically,
3572
+ // so the animation stylesheet is appended once; it is inert until `--skdx-chart-duration` is set.
3573
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
3574
+ style.textContent = ANIMATION_CSS;
3575
+ this.svg().nativeElement.prepend(style);
3576
+ if (typeof ResizeObserver === 'undefined')
3577
+ return;
3578
+ const ro = new ResizeObserver(([entry]) => {
3579
+ const w = Math.floor(entry?.contentRect.width ?? 0);
3580
+ if (w > 0 && this.responsive())
3581
+ this.measured.emit(w);
3582
+ });
3583
+ ro.observe(host);
3584
+ destroy.onDestroy(() => ro.disconnect());
3585
+ });
3586
+ }
3587
+ viewBox(e) {
3588
+ const { width, height } = this.layout();
3589
+ return toViewBox(e.currentTarget, e.clientX, e.clientY, width, height);
1657
3590
  }
1658
3591
  onPointerMove(e) {
1659
- if (!this.layout().tooltip)
3592
+ const layout = this.layout();
3593
+ if (this.pointers.has(e.pointerId))
3594
+ this.pointers.set(e.pointerId, this.viewBox(e).x);
3595
+ if (this.drag && layout.hit) {
3596
+ const d = this.drag;
3597
+ if (d.grab === 'pinch') {
3598
+ const [a, b] = [...this.pointers.values()];
3599
+ if (a === undefined || b === undefined || !d.distance)
3600
+ return;
3601
+ this.zoomChange.emit(zoomPinch(layout.hit, d.zoom, Math.abs(a - b) / d.distance, (a + b) / 2));
3602
+ return;
3603
+ }
3604
+ const { x } = this.viewBox(e);
3605
+ this.zoomChange.emit(d.grab === 'pan'
3606
+ ? zoomPan(layout.hit, d.zoom, x - d.x)
3607
+ : brushDrag(layout.hit, d.grab, d.zoom, x - d.x));
1660
3608
  return;
1661
- const { width, height } = this.layout();
1662
- const next = hoverTooltip(e.target, e.currentTarget, e.clientX, e.clientY, width, height);
3609
+ }
3610
+ if (!layout.tooltip)
3611
+ return;
3612
+ const next = pointerShapes(layout, e.target, e.currentTarget, e.clientX, e.clientY);
1663
3613
  if (next.length > 0 || this.tip().length > 0)
1664
3614
  this.tip.set(next);
1665
3615
  }
3616
+ onPointerDown(e) {
3617
+ const hit = this.layout().hit;
3618
+ if (!hit || !this.canZoom() || e.button !== 0)
3619
+ return;
3620
+ const { x, y } = this.viewBox(e);
3621
+ this.pointers.set(e.pointerId, x);
3622
+ if (this.pointers.size === 2) {
3623
+ // A second finger turns any drag into a pinch around the midpoint.
3624
+ const [a, b] = [...this.pointers.values()];
3625
+ this.drag = { x: (a + b) / 2, zoom: hit.zoom, grab: 'pinch', distance: Math.abs(a - b) || 1 };
3626
+ }
3627
+ else {
3628
+ const grabbed = brushGrab(hit, x, y);
3629
+ if (grabbed) {
3630
+ this.drag = { x, zoom: grabbed.zoom, grab: grabbed.grab };
3631
+ if (grabbed.zoom !== hit.zoom)
3632
+ this.zoomChange.emit(grabbed.zoom);
3633
+ }
3634
+ else if (this.zoomable() &&
3635
+ x >= hit.plot.x &&
3636
+ x <= hit.plot.x + hit.plot.width &&
3637
+ y >= hit.plot.y &&
3638
+ y <= hit.plot.y + hit.plot.height)
3639
+ this.drag = { x, zoom: hit.zoom, grab: 'pan' };
3640
+ else {
3641
+ this.pointers.delete(e.pointerId);
3642
+ return;
3643
+ }
3644
+ }
3645
+ e.currentTarget.setPointerCapture(e.pointerId);
3646
+ this.tip.set([]);
3647
+ }
3648
+ onPointerUp(e) {
3649
+ this.pointers.delete(e.pointerId);
3650
+ const el = e.currentTarget;
3651
+ if (el.hasPointerCapture(e.pointerId))
3652
+ el.releasePointerCapture(e.pointerId);
3653
+ if (this.pointers.size === 0 || this.drag?.grab === 'pinch')
3654
+ this.drag = null;
3655
+ }
3656
+ onWheel(e) {
3657
+ const hit = this.layout().hit;
3658
+ if (!hit || !this.canZoom())
3659
+ return;
3660
+ const { x, y } = this.viewBox(e);
3661
+ if (y < hit.plot.y || y > hit.plot.y + hit.plot.height)
3662
+ return;
3663
+ const next = zoomWheel(hit, e.deltaY, x);
3664
+ if (!next)
3665
+ return;
3666
+ e.preventDefault();
3667
+ this.zoomChange.emit(next);
3668
+ }
3669
+ onClick(e) {
3670
+ const hit = itemAt(this.layout(), e.target);
3671
+ if (!hit)
3672
+ return;
3673
+ if (hit.item.legend && hit.item.series !== undefined)
3674
+ this.hiddenSeriesChange.emit([hit.item.series]);
3675
+ else
3676
+ this.itemClick.emit(hit.item);
3677
+ }
3678
+ onKeyDown(e) {
3679
+ if (!this.focusable())
3680
+ return;
3681
+ const focused = this.focused();
3682
+ if ((e.key === 'Enter' || e.key === ' ') && focused !== null) {
3683
+ const item = this.layout().shapes[focused]?.item;
3684
+ if (item) {
3685
+ e.preventDefault();
3686
+ this.itemClick.emit(item);
3687
+ }
3688
+ return;
3689
+ }
3690
+ const next = nextFocus(this.layout(), focused, e.key);
3691
+ if (next === undefined)
3692
+ return;
3693
+ e.preventDefault();
3694
+ this.focused.set(next);
3695
+ }
1666
3696
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartRootComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1667
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.4", type: SkdxChartRootComponent, isStandalone: true, selector: "skdx-chart-root", inputs: { layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: true, transformFunction: null }, kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null } }, host: { styleAttribute: "display: inline-block" }, ngImport: i0, template: `
3697
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.4", type: SkdxChartRootComponent, isStandalone: true, selector: "skdx-chart-root", inputs: { layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: true, transformFunction: null }, kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, zoomable: { classPropertyName: "zoomable", publicName: "zoomable", isSignal: true, isRequired: false, transformFunction: null }, responsive: { classPropertyName: "responsive", publicName: "responsive", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick", hiddenSeriesChange: "hiddenSeriesChange", zoomChange: "zoomChange", measured: "measured" }, host: { properties: { "style.display": "responsive() ? 'block' : 'inline-block'" } }, viewQueries: [{ propertyName: "svg", first: true, predicate: ["svg"], descendants: true, isSignal: true }], ngImport: i0, template: `
1668
3698
  <svg
3699
+ #svg
1669
3700
  xmlns="http://www.w3.org/2000/svg"
1670
3701
  [attr.viewBox]="'0 0 ' + layout().width + ' ' + layout().height"
1671
3702
  [attr.width]="layout().width"
1672
3703
  [attr.height]="layout().height"
1673
- role="img"
1674
- [attr.aria-label]="label()"
3704
+ [attr.role]="layout().decorative ? 'presentation' : 'img'"
3705
+ [attr.aria-hidden]="layout().decorative ? 'true' : null"
3706
+ [attr.aria-label]="layout().decorative ? null : label()"
3707
+ [attr.aria-describedby]="descId()"
3708
+ [attr.tabindex]="focusable() ? 0 : null"
1675
3709
  [attr.data-skdx-chart]="kind()"
1676
3710
  [attr.font-family]="colors.fontFamily"
1677
3711
  [attr.font-size]="colors.fontSize"
1678
- style="max-width: 100%; height: auto"
3712
+ [style.max-width]="'100%'"
3713
+ [style.height]="'auto'"
3714
+ [style.outline]="'none'"
3715
+ [style.cursor]="canZoom() ? 'grab' : null"
3716
+ [style.touch-action]="canZoom() ? 'none' : null"
3717
+ [style.--skdx-chart-duration]="duration()"
1679
3718
  (pointermove)="onPointerMove($event)"
1680
3719
  (pointerleave)="tip.set([])"
3720
+ (pointerdown)="onPointerDown($event)"
3721
+ (pointerup)="onPointerUp($event)"
3722
+ (pointercancel)="onPointerUp($event)"
3723
+ (wheel)="onWheel($event)"
3724
+ (dblclick)="canZoom() && zoomChange.emit(undefined)"
3725
+ (click)="onClick($event)"
3726
+ (keydown)="onKeyDown($event)"
3727
+ (blur)="focused.set(null)"
1681
3728
  >
1682
3729
  <title>{{ label() }}</title>
1683
- <svg:g skdxChartShapes [shapes]="layout().shapes"></svg:g>
1684
- <svg:g skdxChartShapes [shapes]="tip()"></svg:g>
3730
+ @if (layout().description) {
3731
+ <svg:desc [attr.id]="descId()">{{ layout().description }}</svg:desc>
3732
+ }
3733
+ <svg:g
3734
+ skdxChartShapes
3735
+ [shapes]="layout().shapes"
3736
+ ></svg:g>
3737
+ <svg:g
3738
+ skdxChartShapes
3739
+ [shapes]="tip()"
3740
+ ></svg:g>
3741
+ <svg:g
3742
+ skdxChartShapes
3743
+ [shapes]="focusShapes()"
3744
+ ></svg:g>
1685
3745
  </svg>
1686
3746
  `, isInline: true, dependencies: [{ kind: "component", type: SkdxChartShapesComponent, selector: "g[skdxChartShapes]", inputs: ["shapes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1687
3747
  }
@@ -1691,31 +3751,61 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1691
3751
  selector: 'skdx-chart-root',
1692
3752
  standalone: true,
1693
3753
  changeDetection: ChangeDetectionStrategy.OnPush,
1694
- host: { style: 'display: inline-block' },
3754
+ host: { '[style.display]': "responsive() ? 'block' : 'inline-block'" },
1695
3755
  imports: [SkdxChartShapesComponent],
1696
3756
  template: `
1697
3757
  <svg
3758
+ #svg
1698
3759
  xmlns="http://www.w3.org/2000/svg"
1699
3760
  [attr.viewBox]="'0 0 ' + layout().width + ' ' + layout().height"
1700
3761
  [attr.width]="layout().width"
1701
3762
  [attr.height]="layout().height"
1702
- role="img"
1703
- [attr.aria-label]="label()"
3763
+ [attr.role]="layout().decorative ? 'presentation' : 'img'"
3764
+ [attr.aria-hidden]="layout().decorative ? 'true' : null"
3765
+ [attr.aria-label]="layout().decorative ? null : label()"
3766
+ [attr.aria-describedby]="descId()"
3767
+ [attr.tabindex]="focusable() ? 0 : null"
1704
3768
  [attr.data-skdx-chart]="kind()"
1705
3769
  [attr.font-family]="colors.fontFamily"
1706
3770
  [attr.font-size]="colors.fontSize"
1707
- style="max-width: 100%; height: auto"
3771
+ [style.max-width]="'100%'"
3772
+ [style.height]="'auto'"
3773
+ [style.outline]="'none'"
3774
+ [style.cursor]="canZoom() ? 'grab' : null"
3775
+ [style.touch-action]="canZoom() ? 'none' : null"
3776
+ [style.--skdx-chart-duration]="duration()"
1708
3777
  (pointermove)="onPointerMove($event)"
1709
3778
  (pointerleave)="tip.set([])"
3779
+ (pointerdown)="onPointerDown($event)"
3780
+ (pointerup)="onPointerUp($event)"
3781
+ (pointercancel)="onPointerUp($event)"
3782
+ (wheel)="onWheel($event)"
3783
+ (dblclick)="canZoom() && zoomChange.emit(undefined)"
3784
+ (click)="onClick($event)"
3785
+ (keydown)="onKeyDown($event)"
3786
+ (blur)="focused.set(null)"
1710
3787
  >
1711
3788
  <title>{{ label() }}</title>
1712
- <svg:g skdxChartShapes [shapes]="layout().shapes"></svg:g>
1713
- <svg:g skdxChartShapes [shapes]="tip()"></svg:g>
3789
+ @if (layout().description) {
3790
+ <svg:desc [attr.id]="descId()">{{ layout().description }}</svg:desc>
3791
+ }
3792
+ <svg:g
3793
+ skdxChartShapes
3794
+ [shapes]="layout().shapes"
3795
+ ></svg:g>
3796
+ <svg:g
3797
+ skdxChartShapes
3798
+ [shapes]="tip()"
3799
+ ></svg:g>
3800
+ <svg:g
3801
+ skdxChartShapes
3802
+ [shapes]="focusShapes()"
3803
+ ></svg:g>
1714
3804
  </svg>
1715
3805
  `,
1716
3806
  }]
1717
- }], propDecorators: { layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: true }] }], kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }] } });
1718
- /** The `<svg>` root, grid, axis and tick labels every axis chart shares; marks are projected. */
3807
+ }], ctorParameters: () => [], propDecorators: { layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: true }] }], kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], zoomable: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomable", required: false }] }], responsive: [{ type: i0.Input, args: [{ isSignal: true, alias: "responsive", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], hiddenSeriesChange: [{ type: i0.Output, args: ["hiddenSeriesChange"] }], zoomChange: [{ type: i0.Output, args: ["zoomChange"] }], measured: [{ type: i0.Output, args: ["measured"] }], svg: [{ type: i0.ViewChild, args: ['svg', { isSignal: true }] }] } });
3808
+ /** Size, state, formatting, accessibility and interaction inputs every chart takes. */
1719
3809
  class SkdxSizedBase {
1720
3810
  constructor() {
1721
3811
  this.width = input(/* @ts-ignore */
@@ -1731,39 +3821,164 @@ class SkdxSizedBase {
1731
3821
  /** Show a hover tooltip for the mark under the pointer. */
1732
3822
  this.tooltip = input(true, /* @ts-ignore */
1733
3823
  ...(ngDevMode ? [{ debugName: "tooltip" }] : /* istanbul ignore next */ []));
3824
+ /** How values are written: a function or an `Intl` style such as `{ style: 'currency', currency: 'EUR' }`. */
3825
+ this.format = input(/* @ts-ignore */
3826
+ ...(ngDevMode ? [undefined, { debugName: "format" }] : /* istanbul ignore next */ []));
3827
+ /** Categorical series colors, cycled by series index. */
3828
+ this.palette = input(/* @ts-ignore */
3829
+ ...(ngDevMode ? [undefined, { debugName: "palette" }] : /* istanbul ignore next */ []));
3830
+ /** Series left out of the plot; two-way bindable, the legend toggles it. */
3831
+ this.hiddenSeries = model(undefined, /* @ts-ignore */
3832
+ ...(ngDevMode ? [{ debugName: "hiddenSeries" }] : /* istanbul ignore next */ []));
3833
+ /** Draw a loading placeholder instead of the chart. */
3834
+ this.loading = input(false, /* @ts-ignore */
3835
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
3836
+ /** Draw this message instead of the chart. */
3837
+ this.error = input(/* @ts-ignore */
3838
+ ...(ngDevMode ? [undefined, { debugName: "error" }] : /* istanbul ignore next */ []));
3839
+ /** Written in place of an empty chart. */
3840
+ this.emptyMessage = input(/* @ts-ignore */
3841
+ ...(ngDevMode ? [undefined, { debugName: "emptyMessage" }] : /* istanbul ignore next */ []));
3842
+ /** Longer accessible description, rendered as the svg `<desc>`. */
3843
+ this.description = input(/* @ts-ignore */
3844
+ ...(ngDevMode ? [undefined, { debugName: "description" }] : /* istanbul ignore next */ []));
3845
+ /** Hide the chart from assistive technology. */
3846
+ this.decorative = input(false, /* @ts-ignore */
3847
+ ...(ngDevMode ? [{ debugName: "decorative" }] : /* istanbul ignore next */ []));
3848
+ /** Fade marks in and transition them on change; `true` is 300 ms, a number sets the duration. */
3849
+ this.animate = input(false, /* @ts-ignore */
3850
+ ...(ngDevMode ? [{ debugName: "animate" }] : /* istanbul ignore next */ []));
3851
+ /** Lay the chart out at the host's width, so text keeps its size. */
3852
+ this.responsive = input(false, /* @ts-ignore */
3853
+ ...(ngDevMode ? [{ debugName: "responsive" }] : /* istanbul ignore next */ []));
3854
+ /** The width the root measured under `responsive`. */
3855
+ this.measuredWidth = signal(undefined, /* @ts-ignore */
3856
+ ...(ngDevMode ? [{ debugName: "measuredWidth" }] : /* istanbul ignore next */ []));
3857
+ /** Visible category window; two-way bindable, wheel, drag, pinch and brush update it. */
3858
+ this.zoom = model(undefined, /* @ts-ignore */
3859
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
3860
+ /** Wheel zooms and drag pans the category axis. */
3861
+ this.zoomable = input(false, /* @ts-ignore */
3862
+ ...(ngDevMode ? [{ debugName: "zoomable" }] : /* istanbul ignore next */ []));
3863
+ /** A mark was clicked, or Enter/Space was pressed on the focused mark. */
3864
+ this.itemClick = output();
3865
+ }
3866
+ /** Applies a legend toggle reported by the root. */
3867
+ toggle([name]) {
3868
+ const current = this.hiddenSeries() ?? [];
3869
+ if (name === undefined)
3870
+ return;
3871
+ this.hiddenSeries.set(current.includes(name) ? current.filter((n) => n !== name) : [...current, name]);
1734
3872
  }
1735
3873
  sized() {
1736
3874
  return {
1737
- width: this.width(),
3875
+ width: this.responsive() && this.measuredWidth() ? this.measuredWidth() : this.width(),
1738
3876
  height: this.height(),
3877
+ animate: this.animate(),
3878
+ responsive: this.responsive(),
1739
3879
  legend: this.legend(),
1740
3880
  tooltip: this.tooltip(),
3881
+ format: this.format(),
3882
+ palette: this.palette(),
3883
+ hiddenSeries: this.hiddenSeries(),
3884
+ loading: this.loading(),
3885
+ error: this.error(),
3886
+ emptyMessage: this.emptyMessage(),
3887
+ description: this.description(),
3888
+ decorative: this.decorative(),
3889
+ zoom: this.zoom(),
1741
3890
  };
1742
3891
  }
1743
3892
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxSizedBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1744
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.4", type: SkdxSizedBase, isStandalone: true, inputs: { width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, legend: { classPropertyName: "legend", publicName: "legend", isSignal: true, isRequired: false, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
3893
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.4", type: SkdxSizedBase, isStandalone: true, inputs: { width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, legend: { classPropertyName: "legend", publicName: "legend", isSignal: true, isRequired: false, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, palette: { classPropertyName: "palette", publicName: "palette", isSignal: true, isRequired: false, transformFunction: null }, hiddenSeries: { classPropertyName: "hiddenSeries", publicName: "hiddenSeries", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, decorative: { classPropertyName: "decorative", publicName: "decorative", isSignal: true, isRequired: false, transformFunction: null }, animate: { classPropertyName: "animate", publicName: "animate", isSignal: true, isRequired: false, transformFunction: null }, responsive: { classPropertyName: "responsive", publicName: "responsive", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null }, zoomable: { classPropertyName: "zoomable", publicName: "zoomable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { hiddenSeries: "hiddenSeriesChange", zoom: "zoomChange", itemClick: "itemClick" }, ngImport: i0 }); }
1745
3894
  }
1746
3895
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxSizedBase, decorators: [{
1747
3896
  type: Directive
1748
- }], propDecorators: { width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], legend: [{ type: i0.Input, args: [{ isSignal: true, alias: "legend", required: false }] }], tooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltip", required: false }] }] } });
1749
- /** Inputs the category charts take. */
3897
+ }], propDecorators: { width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], legend: [{ type: i0.Input, args: [{ isSignal: true, alias: "legend", required: false }] }], tooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltip", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", required: false }] }], palette: [{ type: i0.Input, args: [{ isSignal: true, alias: "palette", required: false }] }], hiddenSeries: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenSeries", required: false }] }, { type: i0.Output, args: ["hiddenSeriesChange"] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], decorative: [{ type: i0.Input, args: [{ isSignal: true, alias: "decorative", required: false }] }], animate: [{ type: i0.Input, args: [{ isSignal: true, alias: "animate", required: false }] }], responsive: [{ type: i0.Input, args: [{ isSignal: true, alias: "responsive", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }, { type: i0.Output, args: ["zoomChange"] }], zoomable: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomable", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }] } });
3898
+ /** Inputs the cartesian charts (bar, line, area) take. */
1750
3899
  class SkdxChartBase extends SkdxSizedBase {
1751
3900
  constructor() {
1752
3901
  super(...arguments);
1753
3902
  this.series = input.required(/* @ts-ignore */
1754
3903
  ...(ngDevMode ? [{ debugName: "series" }] : /* istanbul ignore next */ []));
3904
+ /** X-axis labels; numbers and dates are written with `xAxis.format`. */
1755
3905
  this.categories = input(/* @ts-ignore */
1756
3906
  ...(ngDevMode ? [undefined, { debugName: "categories" }] : /* istanbul ignore next */ []));
3907
+ this.xAxis = input(/* @ts-ignore */
3908
+ ...(ngDevMode ? [undefined, { debugName: "xAxis" }] : /* istanbul ignore next */ []));
3909
+ this.yAxis = input(/* @ts-ignore */
3910
+ ...(ngDevMode ? [undefined, { debugName: "yAxis" }] : /* istanbul ignore next */ []));
3911
+ /** Enables the second y axis; series opt in with `axis: 'right'`. */
3912
+ this.rightAxis = input(/* @ts-ignore */
3913
+ ...(ngDevMode ? [undefined, { debugName: "rightAxis" }] : /* istanbul ignore next */ []));
3914
+ /** Lines and bands in data coordinates: targets, SLAs, forecast periods. */
3915
+ this.references = input(/* @ts-ignore */
3916
+ ...(ngDevMode ? [undefined, { debugName: "references" }] : /* istanbul ignore next */ []));
3917
+ /** Write each value next to its mark. */
3918
+ this.labels = input(false, /* @ts-ignore */
3919
+ ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
3920
+ this.curve = input(/* @ts-ignore */
3921
+ ...(ngDevMode ? [undefined, { debugName: "curve" }] : /* istanbul ignore next */ []));
3922
+ this.connectNulls = input(false, /* @ts-ignore */
3923
+ ...(ngDevMode ? [{ debugName: "connectNulls" }] : /* istanbul ignore next */ []));
3924
+ /** Stack the series; `'percent'` normalizes each category to 100%. */
3925
+ this.stacked = input(false, /* @ts-ignore */
3926
+ ...(ngDevMode ? [{ debugName: "stacked" }] : /* istanbul ignore next */ []));
3927
+ this.orientation = input(/* @ts-ignore */
3928
+ ...(ngDevMode ? [undefined, { debugName: "orientation" }] : /* istanbul ignore next */ []));
3929
+ /** Corner radius of bars. */
3930
+ this.radius = input(/* @ts-ignore */
3931
+ ...(ngDevMode ? [undefined, { debugName: "radius" }] : /* istanbul ignore next */ []));
3932
+ this.sort = input(/* @ts-ignore */
3933
+ ...(ngDevMode ? [undefined, { debugName: "sort" }] : /* istanbul ignore next */ []));
3934
+ this.transform = input(/* @ts-ignore */
3935
+ ...(ngDevMode ? [undefined, { debugName: "transform" }] : /* istanbul ignore next */ []));
3936
+ this.bins = input(/* @ts-ignore */
3937
+ ...(ngDevMode ? [undefined, { debugName: "bins" }] : /* istanbul ignore next */ []));
3938
+ this.binWidth = input(/* @ts-ignore */
3939
+ ...(ngDevMode ? [undefined, { debugName: "binWidth" }] : /* istanbul ignore next */ []));
3940
+ this.binDomain = input(/* @ts-ignore */
3941
+ ...(ngDevMode ? [undefined, { debugName: "binDomain" }] : /* istanbul ignore next */ []));
3942
+ this.binMode = input(/* @ts-ignore */
3943
+ ...(ngDevMode ? [undefined, { debugName: "binMode" }] : /* istanbul ignore next */ []));
3944
+ /** Draw a range strip under the plot showing the whole series and the zoomed window. */
3945
+ this.brush = input(false, /* @ts-ignore */
3946
+ ...(ngDevMode ? [{ debugName: "brush" }] : /* istanbul ignore next */ []));
3947
+ /** Snap the hover tooltip to the nearest category and list every series. */
3948
+ this.crosshair = input(false, /* @ts-ignore */
3949
+ ...(ngDevMode ? [{ debugName: "crosshair" }] : /* istanbul ignore next */ []));
1757
3950
  }
1758
3951
  input() {
1759
- return { ...this.sized(), series: this.series(), categories: this.categories() };
3952
+ return {
3953
+ ...this.sized(),
3954
+ series: this.series(),
3955
+ categories: this.categories(),
3956
+ xAxis: this.xAxis(),
3957
+ yAxis: this.yAxis(),
3958
+ rightAxis: this.rightAxis(),
3959
+ references: this.references(),
3960
+ labels: this.labels(),
3961
+ curve: this.curve(),
3962
+ connectNulls: this.connectNulls(),
3963
+ stacked: this.stacked(),
3964
+ orientation: this.orientation(),
3965
+ radius: this.radius(),
3966
+ sort: this.sort(),
3967
+ transform: this.transform(),
3968
+ bins: this.bins(),
3969
+ binWidth: this.binWidth(),
3970
+ binDomain: this.binDomain(),
3971
+ binMode: this.binMode(),
3972
+ brush: this.brush(),
3973
+ crosshair: this.crosshair(),
3974
+ };
1760
3975
  }
1761
3976
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartBase, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
1762
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.4", type: SkdxChartBase, isStandalone: true, inputs: { series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, categories: { classPropertyName: "categories", publicName: "categories", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0 }); }
3977
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.4", type: SkdxChartBase, isStandalone: true, inputs: { series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, categories: { classPropertyName: "categories", publicName: "categories", isSignal: true, isRequired: false, transformFunction: null }, xAxis: { classPropertyName: "xAxis", publicName: "xAxis", isSignal: true, isRequired: false, transformFunction: null }, yAxis: { classPropertyName: "yAxis", publicName: "yAxis", isSignal: true, isRequired: false, transformFunction: null }, rightAxis: { classPropertyName: "rightAxis", publicName: "rightAxis", isSignal: true, isRequired: false, transformFunction: null }, references: { classPropertyName: "references", publicName: "references", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, curve: { classPropertyName: "curve", publicName: "curve", isSignal: true, isRequired: false, transformFunction: null }, connectNulls: { classPropertyName: "connectNulls", publicName: "connectNulls", isSignal: true, isRequired: false, transformFunction: null }, stacked: { classPropertyName: "stacked", publicName: "stacked", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, radius: { classPropertyName: "radius", publicName: "radius", isSignal: true, isRequired: false, transformFunction: null }, sort: { classPropertyName: "sort", publicName: "sort", isSignal: true, isRequired: false, transformFunction: null }, transform: { classPropertyName: "transform", publicName: "transform", isSignal: true, isRequired: false, transformFunction: null }, bins: { classPropertyName: "bins", publicName: "bins", isSignal: true, isRequired: false, transformFunction: null }, binWidth: { classPropertyName: "binWidth", publicName: "binWidth", isSignal: true, isRequired: false, transformFunction: null }, binDomain: { classPropertyName: "binDomain", publicName: "binDomain", isSignal: true, isRequired: false, transformFunction: null }, binMode: { classPropertyName: "binMode", publicName: "binMode", isSignal: true, isRequired: false, transformFunction: null }, brush: { classPropertyName: "brush", publicName: "brush", isSignal: true, isRequired: false, transformFunction: null }, crosshair: { classPropertyName: "crosshair", publicName: "crosshair", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0 }); }
1763
3978
  }
1764
3979
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartBase, decorators: [{
1765
3980
  type: Directive
1766
- }], propDecorators: { series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: false }] }] } });
3981
+ }], propDecorators: { series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: false }] }], xAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "xAxis", required: false }] }], yAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "yAxis", required: false }] }], rightAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "rightAxis", required: false }] }], references: [{ type: i0.Input, args: [{ isSignal: true, alias: "references", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], curve: [{ type: i0.Input, args: [{ isSignal: true, alias: "curve", required: false }] }], connectNulls: [{ type: i0.Input, args: [{ isSignal: true, alias: "connectNulls", required: false }] }], stacked: [{ type: i0.Input, args: [{ isSignal: true, alias: "stacked", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], radius: [{ type: i0.Input, args: [{ isSignal: true, alias: "radius", required: false }] }], sort: [{ type: i0.Input, args: [{ isSignal: true, alias: "sort", required: false }] }], transform: [{ type: i0.Input, args: [{ isSignal: true, alias: "transform", required: false }] }], bins: [{ type: i0.Input, args: [{ isSignal: true, alias: "bins", required: false }] }], binWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "binWidth", required: false }] }], binDomain: [{ type: i0.Input, args: [{ isSignal: true, alias: "binDomain", required: false }] }], binMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "binMode", required: false }] }], brush: [{ type: i0.Input, args: [{ isSignal: true, alias: "brush", required: false }] }], crosshair: [{ type: i0.Input, args: [{ isSignal: true, alias: "crosshair", required: false }] }] } });
1767
3982
 
1768
3983
  /*
1769
3984
  * Copyright (c) 2026 SkandaDX
@@ -1775,5 +3990,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1775
3990
  * Generated bundle index. Do not edit.
1776
3991
  */
1777
3992
 
1778
- export { CHART_COLORS, SkdxChartBase, SkdxChartRootComponent, SkdxChartShapesComponent, SkdxSizedBase, hoverTooltip, layoutAreaChart, layoutBarChart, layoutBoxplot, layoutCandlestick, layoutChord, layoutFunnelChart, layoutGantt, layoutHeatmap, layoutLineChart, layoutMap, layoutPieChart, layoutRadarChart, layoutSankey, layoutScatterChart, layoutSparkline, layoutSunburst, layoutTreemap, layoutWaterfall };
3993
+ export { ANIMATION_CSS, ANIMATION_LIMIT, CHART_COLORS, MARKER_LIMIT, SERIES_COLORS, SkdxChartBase, SkdxChartRootComponent, SkdxChartShapesComponent, SkdxSizedBase, TITLE_LIMIT, anchorOf, animationDuration, brushDrag, brushGrab, categoryLabel, crosshairShapes, csv, focusShapes, focusTargets, formatValue, hoverTooltip, isUnzoomed, itemAt, layoutAreaChart, layoutBarChart, layoutBoxplot, layoutCandlestick, layoutCartesian, layoutChord, layoutFunnelChart, layoutGantt, layoutHeatmap, layoutLineChart, layoutMap, layoutPieChart, layoutRadarChart, layoutRadialBar, layoutSankey, layoutScatterChart, layoutSparkline, layoutSunburst, layoutTreemap, layoutWaterfall, makeFormatter, nextFocus, pointerShapes, seriesCsv, shapeMarkup, stateCanvas, svgMarkup, svgToPng, timeTicks, toViewBox, tooltipShapes, variantColor, zoomPan, zoomPinch, zoomWheel };
1779
3994
  //# sourceMappingURL=skdx-angular-charts-internal.mjs.map