@skdx/angular-charts 0.32.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 +27 -33
  3. package/fesm2022/skdx-angular-charts-area.mjs.map +1 -1
  4. package/fesm2022/skdx-angular-charts-bar.mjs +30 -51
  5. package/fesm2022/skdx-angular-charts-bar.mjs.map +1 -1
  6. package/fesm2022/skdx-angular-charts-boxplot.mjs +26 -14
  7. package/fesm2022/skdx-angular-charts-boxplot.mjs.map +1 -1
  8. package/fesm2022/skdx-angular-charts-candlestick.mjs +49 -16
  9. package/fesm2022/skdx-angular-charts-candlestick.mjs.map +1 -1
  10. package/fesm2022/skdx-angular-charts-chord.mjs +26 -14
  11. package/fesm2022/skdx-angular-charts-chord.mjs.map +1 -1
  12. package/fesm2022/skdx-angular-charts-funnel.mjs +37 -48
  13. package/fesm2022/skdx-angular-charts-funnel.mjs.map +1 -1
  14. package/fesm2022/skdx-angular-charts-gantt.mjs +31 -19
  15. package/fesm2022/skdx-angular-charts-gantt.mjs.map +1 -1
  16. package/fesm2022/skdx-angular-charts-heatmap.mjs +54 -81
  17. package/fesm2022/skdx-angular-charts-heatmap.mjs.map +1 -1
  18. package/fesm2022/skdx-angular-charts-internal.mjs +2746 -707
  19. package/fesm2022/skdx-angular-charts-internal.mjs.map +1 -1
  20. package/fesm2022/skdx-angular-charts-line.mjs +26 -16
  21. package/fesm2022/skdx-angular-charts-line.mjs.map +1 -1
  22. package/fesm2022/skdx-angular-charts-map.mjs +35 -14
  23. package/fesm2022/skdx-angular-charts-map.mjs.map +1 -1
  24. package/fesm2022/skdx-angular-charts-pie.mjs +47 -25
  25. package/fesm2022/skdx-angular-charts-pie.mjs.map +1 -1
  26. package/fesm2022/skdx-angular-charts-radar.mjs +45 -81
  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 +26 -14
  31. package/fesm2022/skdx-angular-charts-sankey.mjs.map +1 -1
  32. package/fesm2022/skdx-angular-charts-scatter.mjs +49 -19
  33. package/fesm2022/skdx-angular-charts-scatter.mjs.map +1 -1
  34. package/fesm2022/skdx-angular-charts-sparkline.mjs +50 -60
  35. package/fesm2022/skdx-angular-charts-sparkline.mjs.map +1 -1
  36. package/fesm2022/skdx-angular-charts-sunburst.mjs +26 -14
  37. package/fesm2022/skdx-angular-charts-sunburst.mjs.map +1 -1
  38. package/fesm2022/skdx-angular-charts-treemap.mjs +26 -14
  39. package/fesm2022/skdx-angular-charts-treemap.mjs.map +1 -1
  40. package/fesm2022/skdx-angular-charts-waterfall.mjs +26 -14
  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 -12
  45. package/types/skdx-angular-charts-area.d.ts +2 -2
  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 -15
  53. package/types/skdx-angular-charts-internal.d.ts +595 -232
  54. package/types/skdx-angular-charts-line.d.ts +2 -2
  55. package/types/skdx-angular-charts-map.d.ts +4 -3
  56. package/types/skdx-angular-charts-pie.d.ts +11 -3
  57. package/types/skdx-angular-charts-radar.d.ts +8 -10
  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 +11 -4
  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, computed, 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,102 +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
- /** Attributes for the `<svg>` root every framework renders. */
265
- function svgAttrs(frame, kind, label) {
266
- return {
267
- viewBox: `0 0 ${frame.width} ${frame.height}`,
268
- width: frame.width,
269
- height: frame.height,
270
- role: 'img',
271
- 'aria-label': label,
272
- 'data-skdx-chart': kind,
273
- 'font-family': CHART_COLORS.fontFamily,
274
- 'font-size': CHART_COLORS.fontSize,
275
- };
276
- }
277
- const values = (input) => input.series.flatMap((s) => s.data);
278
458
  /** Tooltip text for a value in a series: `Series, Category: 1,234`. */
279
- const markTitle = (series, category, value) => `${series}, ${category}: ${formatValue(value)}`;
459
+ const markTitle = (series, category, value, format) => `${series}, ${category}: ${format(value)}`;
280
460
  /**
281
- * Tooltip shapes for the mark under the pointer, or none. Reads the mark's
282
- * `<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.
283
463
  */
284
- function hoverTooltip(target, svg, clientX, clientY, width, height) {
285
- const mark = target?.closest?.('[data-skdx-part]');
286
- const title = mark && Array.from(mark.children).find((c) => c.tagName.toLowerCase() === 'title')?.textContent;
287
- if (!title)
288
- return [];
289
- const r = svg.getBoundingClientRect();
290
- const x = r.width ? ((clientX - r.left) * width) / r.width : clientX;
291
- const y = r.height ? ((clientY - r.top) * height) / r.height : clientY;
292
- 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 };
293
497
  }
294
498
 
295
499
  /*
@@ -297,63 +501,216 @@ function hoverTooltip(target, svg, clientX, clientY, width, height) {
297
501
  * This file is part of the SkandaDX organization.
298
502
  * Licensed under the MIT License. See LICENSE in the repository root.
299
503
  */
300
- function layoutBarChart(input) {
301
- const yExtent = input.stacked ? stackedExtent(input) : extent(values(input), true);
302
- const { frame, x, y, categories } = buildFrame(input, yExtent);
303
- const bars = [];
304
- // Running stack tops per category, positives up and negatives down.
305
- const up = new Array(categories.length).fill(0);
306
- const down = new Array(categories.length).fill(0);
307
- const groupWidth = input.stacked ? x.bandwidth : x.bandwidth / Math.max(1, input.series.length);
308
- input.series.forEach((s, si) => {
309
- const color = seriesColor(si, s.color);
310
- categories.forEach((category, ci) => {
311
- const value = s.data[ci];
312
- if (value === undefined)
313
- return;
314
- let from = 0;
315
- let to = value;
316
- if (input.stacked) {
317
- const stack = value >= 0 ? up : down;
318
- from = stack[ci];
319
- to = from + value;
320
- stack[ci] = to;
321
- }
322
- const y0 = y(from);
323
- const y1 = y(to);
324
- bars.push({
325
- series: s.name,
326
- category,
327
- value,
328
- color,
329
- title: markTitle(s.name, category, value),
330
- x: px(x.start(ci) + (input.stacked ? 0 : si * groupWidth)),
331
- y: px(Math.min(y0, y1)),
332
- width: px(groupWidth),
333
- height: px(Math.abs(y1 - y0)),
504
+ /** Grid, axis line and tick labels of an axis frame, as shapes. */
505
+ function frameShapes(frame, options = {}) {
506
+ const { plot, xTicks, yTicks, baseline } = frame;
507
+ const right = plot.x + plot.width;
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,
334
521
  });
522
+ shapes.push({
523
+ kind: 'line',
524
+ part: 'axis',
525
+ x1: baseline,
526
+ x2: baseline,
527
+ y1: plot.y,
528
+ y2: bottom,
529
+ stroke: CHART_COLORS.axis,
335
530
  });
336
- });
337
- return { ...frame, bars };
338
- }
339
- function stackedExtent(input) {
340
- let min = 0;
341
- let max = 0;
342
- const count = Math.max(0, ...input.series.map((s) => s.data.length));
343
- for (let ci = 0; ci < count; ci++) {
344
- let up = 0;
345
- let down = 0;
346
- for (const s of input.series) {
347
- const v = s.data[ci] ?? 0;
348
- if (v >= 0)
349
- up += v;
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({
548
+ kind: 'line',
549
+ part: 'axis',
550
+ x1: plot.x,
551
+ x2: right,
552
+ y1: baseline,
553
+ y2: baseline,
554
+ stroke: CHART_COLORS.axis,
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',
570
+ anchor: 'middle',
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;
577
+ }
578
+ const label = (x, y, text, extra = {}) => ({ kind: 'text', part: 'label', x, y, text, fill: CHART_COLORS.label, ...extra });
579
+ /** A dot with its native tooltip. */
580
+ const point = (cx, cy, r, fill, title, item) => ({ kind: 'circle', part: 'point', cx, cy, r, fill, title, item });
581
+
582
+ /*
583
+ * Copyright (c) 2026 SkandaDX
584
+ * This file is part of the SkandaDX organization.
585
+ * Licensed under the MIT License. See LICENSE in the repository root.
586
+ */
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));
608
+ }
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));
628
+ else
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));
350
640
  else
351
- down += v;
641
+ shapes.push(...vband(clampX(lo), clampX(hi), plot, color, r.label));
352
642
  }
353
- min = Math.min(min, down);
354
- max = Math.max(max, up);
355
643
  }
356
- 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];
357
714
  }
358
715
 
359
716
  /*
@@ -361,39 +718,546 @@ function stackedExtent(input) {
361
718
  * This file is part of the SkandaDX organization.
362
719
  * Licensed under the MIT License. See LICENSE in the repository root.
363
720
  */
364
- function polylines(input, { frame, x, y, categories }) {
365
- const points = [];
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);
919
+ const areas = [];
920
+ const bars = [];
366
921
  const lines = [];
367
- const ends = [];
368
- input.series.forEach((s, si) => {
369
- const color = seriesColor(si, s.color);
370
- const coords = s.data.map((value, ci) => {
371
- const p = {
372
- series: s.name,
373
- color,
374
- title: markTitle(s.name, categories[ci], value),
375
- x: px(x.center(ci)),
376
- y: px(y(value)),
377
- };
378
- points.push(p);
379
- return p;
380
- });
381
- lines.push({ series: s.name, color, d: pathThrough(coords, input.curve) });
382
- ends.push({ first: px(x.center(0)), last: px(x.center(Math.max(0, s.data.length - 1))) });
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,
383
931
  });
384
- return { frame, lines, points, ends };
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 });
1000
+ });
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({
1031
+ kind: 'path',
1032
+ part: 'line',
1033
+ d,
1034
+ stroke: s.color,
1035
+ strokeWidth: 2,
1036
+ strokeLinecap: 'round',
1037
+ strokeLinejoin: 'round',
1038
+ dash: s.dash,
1039
+ });
1040
+ }
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
+ }
1157
+ return {
1158
+ ...frame,
1159
+ shapes: [
1160
+ ...shapes,
1161
+ ...areas,
1162
+ ...bars,
1163
+ ...lines,
1164
+ ...points,
1165
+ ...texts,
1166
+ ...brush,
1167
+ ...frame.shapes,
1168
+ ],
1169
+ hit,
1170
+ };
385
1171
  }
386
- function layoutLineChart(input) {
387
- const { frame, lines, points } = polylines(input, buildFrame(input, extent(values(input), false)));
388
- return { ...frame, lines, points };
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 };
389
1180
  }
390
- function layoutAreaChart(input) {
391
- const { frame, lines, points, ends } = polylines(input, buildFrame(input, extent(values(input), true)));
392
- const areas = lines.map((l, i) => {
393
- const { first, last } = ends[i];
394
- return { ...l, d: l.d ? `${l.d}L${last} ${frame.baseline}L${first} ${frame.baseline}Z` : '' };
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
+ };
395
1205
  });
396
- return { ...frame, lines, points, areas };
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 };
397
1261
  }
398
1262
 
399
1263
  /*
@@ -401,32 +1265,127 @@ function layoutAreaChart(input) {
401
1265
  * This file is part of the SkandaDX organization.
402
1266
  * Licensed under the MIT License. See LICENSE in the repository root.
403
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]);
404
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;
405
1298
  const { width, height } = size(input);
406
- const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
407
- const pairs = input.series.flatMap((s) => s.data);
408
- const x = axis(extent(pairs.map((p) => p[0]), false), [plot.x, plot.x + plot.width]);
409
- const y = axis(extent(pairs.map((p) => p[1]), false), [plot.y + plot.height, plot.y]);
410
- const points = input.series.flatMap((s, si) => {
411
- const color = seriesColor(si, s.color);
412
- return s.data.map(([vx, vy]) => ({
413
- series: s.name,
414
- color,
415
- title: `${s.name}: ${formatValue(vx)}, ${formatValue(vy)}`,
416
- x: px(x.scale(vx)),
417
- y: px(y.scale(vy)),
418
- }));
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),
419
1303
  });
420
- const legend = input.series.map((s, i) => ({ name: s.name, color: seriesColor(i, s.color) }));
421
- return {
422
- ...canvas(input, width, height, legend),
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
+ }
1351
+ const frame = { ...c, plot, xTicks: x.ticks, yTicks: y.ticks, baseline: y.zero };
1352
+ const references = referenceShapes(input.references, {
423
1353
  plot,
424
- xTicks: x.ticks,
425
- yTicks: y.ticks,
426
- baseline: y.zero,
427
- points,
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
+ ],
428
1368
  };
429
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 };
1388
+ }
430
1389
 
431
1390
  /*
432
1391
  * Copyright (c) 2026 SkandaDX
@@ -449,45 +1408,69 @@ function arc(cx, cy, r, r0, a0, a1) {
449
1408
  : `${outer}L${px(cx)} ${px(cy)}Z`;
450
1409
  }
451
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);
452
1415
  const { width, height } = size(input, 300, 300);
453
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;
454
1422
  const cx = width / 2;
455
- const cy = top + (height - top) / 2;
456
- 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;
457
1431
  const r0 = r * Math.min(0.95, Math.max(0, input.innerRadius ?? 0));
458
- const total = input.data.reduce((sum, d) => sum + Math.max(0, d.value), 0);
459
- 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;
460
1438
  const labels = [];
461
- const slices = input.data.map((d, i) => {
462
- 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;
463
1441
  const a0 = angle;
464
- angle += percent * TAU$2;
1442
+ angle += percent * sweep;
1443
+ const share = `${Math.round(percent * 100)}%`;
465
1444
  if (input.labels && percent > 0.03) {
466
1445
  const mid = (a0 + angle) / 2;
467
1446
  const lr = (r + r0) / 2;
468
- labels.push({
469
- kind: 'text',
1447
+ labels.push(label(px(cx + lr * Math.sin(mid)), px(cy - lr * Math.cos(mid)), `${d.name} ${share}`, {
470
1448
  part: 'slice-label',
471
- x: px(cx + lr * Math.sin(mid)),
472
- y: px(cy - lr * Math.cos(mid)),
473
- text: `${d.name} ${Math.round(percent * 100)}%`,
474
1449
  anchor: 'middle',
475
1450
  baseline: 'middle',
476
1451
  fill: 'var(--skdx-color-text-on-accent)',
477
- });
1452
+ }));
478
1453
  }
1454
+ const title = `${d.name}: ${format(d.value)} (${share})`;
479
1455
  return {
480
- name: d.name,
481
- value: d.value,
482
- percent,
483
- series: d.name,
484
- color: seriesColor(i, d.color),
485
- title: `${d.name}: ${formatValue(d.value)} (${Math.round(percent * 100)}%)`,
1456
+ kind: 'path',
1457
+ part: 'slice',
486
1458
  d: percent > 0 ? arc(cx, cy, r, r0, a0, angle) : '',
1459
+ fill: colorOf(input, i, d.color),
1460
+ title,
1461
+ item: percent > 0 ? { label: title, name: d.name, index: i, value: d.value } : undefined,
487
1462
  };
488
1463
  });
489
- const c = canvas(input, width, height, slices.map((s) => ({ name: s.name, color: s.color })));
490
- return { ...c, shapes: [...c.shapes, ...labels], slices };
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) })));
1473
+ return { ...c, shapes: [...slices, ...c.shapes, ...labels] };
491
1474
  }
492
1475
 
493
1476
  /*
@@ -496,24 +1479,87 @@ function layoutPieChart(input) {
496
1479
  * Licensed under the MIT License. See LICENSE in the repository root.
497
1480
  */
498
1481
  const PAD$3 = 2;
1482
+ /** A small axis-less trend: an area (default), a bare line, or bars; optional extremes and last value. */
499
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);
500
1489
  const { width, height } = size(input, 120, 32);
501
1490
  const n = input.data.length;
502
- const y = linearScale(extent(input.data, false), [height - PAD$3, PAD$3]);
503
- const x = (i) => (n < 2 ? width / 2 : PAD$3 + (i / (n - 1)) * (width - 2 * PAD$3));
504
- const coords = input.data.map((v, i) => ({ x: px(x(i)), y: px(y(v)) }));
505
- const line = pathThrough(coords, input.curve);
506
- const last = coords[coords.length - 1];
507
- const first = coords[0];
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({
1530
+ kind: 'path',
1531
+ part: 'area',
1532
+ d: line && head && tail
1533
+ ? `${line}L${tail.x} ${height - PAD$3}L${head.x} ${height - PAD$3}Z`
1534
+ : '',
1535
+ fill: color,
1536
+ fillOpacity: 0.15,
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
+ }));
508
1554
  return {
509
1555
  width,
510
1556
  height,
511
- shapes: [],
512
1557
  tooltip: input.tooltip ?? true,
513
- color: seriesColor(0, input.color),
514
- line,
515
- area: line && first && last ? `${line}L${last.x} ${height - PAD$3}L${first.x} ${height - PAD$3}Z` : '',
516
- last,
1558
+ description: input.description,
1559
+ decorative: input.decorative,
1560
+ animate: input.animate === true ? 300 : input.animate || undefined,
1561
+ responsive: input.responsive,
1562
+ shapes,
517
1563
  };
518
1564
  }
519
1565
 
@@ -523,6 +1569,10 @@ function layoutSparkline(input) {
523
1569
  * Licensed under the MIT License. See LICENSE in the repository root.
524
1570
  */
525
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);
526
1576
  const { width, height } = size(input, 300, 300);
527
1577
  const top = legendTop(input);
528
1578
  const cx = width / 2;
@@ -530,19 +1580,20 @@ function layoutRadarChart(input) {
530
1580
  const radius = Math.max(0, Math.min(width, height - top) / 2 - 36);
531
1581
  const n = input.categories.length;
532
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));
533
1584
  // Normalized: every series is scaled to its own maximum and the rings read as percentages.
534
1585
  const ticks = input.normalize
535
1586
  ? [0, 25, 50, 75, 100]
536
- : niceTicks(0, Math.max(0, ...input.series.flatMap((s) => s.data)), 4);
537
- const max = ticks[ticks.length - 1];
538
- 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]));
539
1590
  const norm = (si, v) => input.normalize
540
1591
  ? seriesMax[si] > 0
541
1592
  ? (v / seriesMax[si]) * 100
542
1593
  : 0
543
1594
  : v;
544
1595
  const at = (i, value) => {
545
- const r = max > 0 ? (value / max) * radius : 0;
1596
+ const r = max > 0 ? (Math.min(value, max) / max) * radius : 0;
546
1597
  return { x: px(cx + r * Math.cos(angle(i))), y: px(cy + r * Math.sin(angle(i))) };
547
1598
  };
548
1599
  const polygon = (value) => n === 0
@@ -553,147 +1604,757 @@ function layoutRadarChart(input) {
553
1604
  return `${p.x} ${p.y}`;
554
1605
  })
555
1606
  .join('L')}Z`;
1607
+ // Concentric grid polygons, one per tick, innermost first, then a spoke per category.
1608
+ const rings = ticks.filter((t) => t > 0 && t <= max);
1609
+ const grid = [
1610
+ ...rings.map((t) => ({
1611
+ kind: 'path',
1612
+ part: 'grid',
1613
+ d: polygon(() => t),
1614
+ stroke: CHART_COLORS.grid,
1615
+ })),
1616
+ ...input.categories.map((_, i) => {
1617
+ const p = at(i, max);
1618
+ return {
1619
+ kind: 'line',
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
+ ];
556
1642
  const points = [];
557
- const polygons = input.series.map((s, si) => {
558
- const color = seriesColor(si, s.color);
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
+ };
559
1652
  input.categories.forEach((category, i) => {
560
- const value = s.data[i] ?? 0;
561
- points.push({
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,
562
1660
  series: s.name,
563
- color,
564
- title: markTitle(s.name, category, value),
565
- ...at(i, norm(si, value)),
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 },
566
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
+ }));
567
1786
  });
568
- return { series: s.name, color, d: polygon((i) => norm(si, s.data[i] ?? 0)) };
569
1787
  });
1788
+ const c = canvas(input, width, height);
570
1789
  return {
571
- ...canvas(input, width, height, polygons.map((p) => ({ name: p.series, color: p.color }))),
572
- rings: ticks
573
- .filter((t) => t > 0)
574
- .map((t) => ({
575
- d: polygon(() => t),
576
- label: { x: px(cx + 4), y: at(0, t).y, label: input.normalize ? `${t}%` : formatValue(t) },
577
- })),
578
- spokes: input.categories.map((_, i) => ({ x1: px(cx), y1: px(cy), ...toXY(at(i, max)) })),
579
- axisLabels: input.categories.map((label, i) => {
580
- const cos = Math.cos(angle(i));
581
- const r = radius + 14;
582
- return {
583
- x: px(cx + r * cos),
584
- y: px(cy + r * Math.sin(angle(i))),
585
- label,
586
- anchor: cos > 0.1 ? 'start' : cos < -0.1 ? 'end' : 'middle',
587
- };
588
- }),
589
- polygons,
590
- points,
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
+ ],
591
1809
  };
592
1810
  }
593
- const toXY = (p) => ({ x2: p.x, y2: p.y });
594
1811
 
595
1812
  /*
596
1813
  * Copyright (c) 2026 SkandaDX
597
1814
  * This file is part of the SkandaDX organization.
598
1815
  * Licensed under the MIT License. See LICENSE in the repository root.
599
1816
  */
600
- const MARGIN = { top: 24, right: 8, bottom: 8, left: 64 };
601
- function layoutHeatmap(input) {
602
- const { width, height } = size(input);
603
- const plot = plotBox(width, height, MARGIN);
604
- const cellW = plot.width / Math.max(1, input.columns.length);
605
- const cellH = plot.height / Math.max(1, input.rows.length);
606
- const all = input.data.flat();
607
- const min = Math.min(...all);
608
- const max = Math.max(...all);
609
- const color = seriesColor(0, input.color);
610
- const negative = input.negativeColor ?? 'var(--skdx-color-danger-500)';
611
- const span = Math.max(Math.abs(min), Math.abs(max));
612
- // Diverging: opacity is the distance from zero, color the sign.
613
- const opacity = (v) => input.diverging
614
- ? span === 0
615
- ? 1
616
- : px(0.15 + (0.85 * Math.abs(v)) / span)
617
- : max === min
618
- ? 1
619
- : px(0.15 + (0.85 * (v - min)) / (max - min));
620
- const cells = [];
621
- input.rows.forEach((row, ri) => {
622
- input.columns.forEach((column, ci) => {
623
- const value = input.data[ri]?.[ci];
624
- if (value === undefined)
625
- return;
626
- cells.push({
627
- series: row,
628
- row,
629
- column,
630
- value,
631
- color: input.diverging && value < 0 ? negative : color,
632
- opacity: opacity(value),
633
- title: `${row}, ${column}: ${formatValue(value)}`,
634
- x: px(plot.x + ci * cellW),
635
- y: px(plot.y + ri * cellH),
636
- width: px(cellW),
637
- height: px(cellH),
638
- });
639
- });
640
- });
641
- return {
642
- ...canvas(input, width, height),
643
- plot,
644
- cells,
645
- rowLabels: input.rows.map((label, i) => ({
646
- x: plot.x - 8,
647
- y: px(plot.y + (i + 0.5) * cellH),
648
- label,
649
- })),
650
- columnLabels: input.columns.map((label, i) => ({
651
- x: px(plot.x + (i + 0.5) * cellW),
652
- y: plot.y - 8,
653
- label,
654
- })),
655
- };
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,
2146
+ };
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 };
2268
+ }
2269
+
2270
+ /*
2271
+ * Copyright (c) 2026 SkandaDX
2272
+ * This file is part of the SkandaDX organization.
2273
+ * Licensed under the MIT License. See LICENSE in the repository root.
2274
+ */
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
+ }
656
2296
  }
657
-
658
- /*
659
- * Copyright (c) 2026 SkandaDX
660
- * This file is part of the SkandaDX organization.
661
- * Licensed under the MIT License. See LICENSE in the repository root.
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)`.
662
2301
  */
663
- const PAD$2 = 8;
664
- const GAP = 4;
665
- function layoutFunnelChart(input) {
666
- const { width, height } = size(input, 400, 300);
667
- const n = input.data.length;
668
- const max = Math.max(0, ...input.data.map((d) => d.value));
669
- const first = input.data[0]?.value ?? 0;
670
- const full = Math.max(0, width - 2 * PAD$2);
671
- const offset = legendTop(input);
672
- const stageH = n === 0 ? 0 : (height - offset - 2 * PAD$2) / n;
673
- const widthOf = (value) => (max > 0 ? (Math.max(0, value) / max) * full : 0);
674
- const stages = input.data.map((d, i) => {
675
- const top = widthOf(d.value);
676
- const bottom = widthOf(input.data[i + 1]?.value ?? d.value);
677
- const y0 = offset + PAD$2 + i * stageH;
678
- const y1 = y0 + stageH - GAP;
679
- const cx = width / 2;
680
- return {
681
- name: d.name,
682
- value: d.value,
683
- percent: first > 0 ? Math.max(0, d.value) / first : 0,
684
- series: d.name,
685
- color: seriesColor(i, d.color),
686
- title: `${d.name}: ${formatValue(d.value)} (${Math.round((first > 0 ? d.value / first : 0) * 100)}%)`,
687
- 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`,
688
- x: px(cx),
689
- y: px((y0 + y1) / 2),
690
- label: `${d.name} · ${formatValue(d.value)}`,
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'));
691
2334
  };
2335
+ img.src = url;
692
2336
  });
693
- return {
694
- ...canvas(input, width, height, stages.map((s) => ({ name: s.name, color: s.color }))),
695
- stages,
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;
696
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);
697
2358
  }
698
2359
 
699
2360
  /*
@@ -701,59 +2362,35 @@ function layoutFunnelChart(input) {
701
2362
  * This file is part of the SkandaDX organization.
702
2363
  * Licensed under the MIT License. See LICENSE in the repository root.
703
2364
  */
704
- /** Grid, axis line and tick labels of an axis frame, as shapes. */
705
- function frameShapes(frame) {
706
- const { plot, xTicks, yTicks, baseline } = frame;
707
- const right = plot.x + plot.width;
708
- return [
709
- ...yTicks.map((t) => ({
710
- kind: 'line',
711
- part: 'grid',
712
- x1: plot.x,
713
- x2: right,
714
- y1: t.pos,
715
- y2: t.pos,
716
- stroke: CHART_COLORS.grid,
717
- })),
718
- {
719
- kind: 'line',
720
- part: 'axis',
721
- x1: plot.x,
722
- x2: right,
723
- y1: baseline,
724
- y2: baseline,
725
- stroke: CHART_COLORS.axis,
726
- },
727
- ...yTicks.map((t) => ({
728
- kind: 'text',
729
- part: 'y-label',
730
- x: plot.x - 8,
731
- y: t.pos,
732
- text: t.label,
733
- anchor: 'end',
734
- baseline: 'middle',
735
- fill: CHART_COLORS.label,
736
- })),
737
- ...xTicks.map((t) => ({
738
- kind: 'text',
739
- part: 'x-label',
740
- x: t.pos,
741
- y: plot.y + plot.height + 20,
742
- text: t.label,
743
- anchor: 'middle',
744
- fill: CHART_COLORS.label,
745
- })),
746
- ];
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;
747
2393
  }
748
- const label = (x, y, text, extra = {}) => ({
749
- kind: 'text',
750
- part: 'label',
751
- x,
752
- y,
753
- text,
754
- fill: CHART_COLORS.label,
755
- ...extra,
756
- });
757
2394
 
758
2395
  /*
759
2396
  * Copyright (c) 2026 SkandaDX
@@ -763,6 +2400,11 @@ const label = (x, y, text, extra = {}) => ({
763
2400
  const NODE_WIDTH = 16;
764
2401
  const NODE_PAD = 8;
765
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);
766
2408
  const { width, height } = size(input);
767
2409
  const plot = plotBox(width, height, { top: 8 + legendTop(input), right: 96, bottom: 8, left: 8 });
768
2410
  const nodes = new Map();
@@ -773,7 +2415,7 @@ function layoutSankey(input) {
773
2415
  name,
774
2416
  depth: 0,
775
2417
  value: 0,
776
- color: seriesColor(nodes.size),
2418
+ color: colorOf(input, nodes.size),
777
2419
  x: 0,
778
2420
  y: 0,
779
2421
  height: 0,
@@ -784,7 +2426,7 @@ function layoutSankey(input) {
784
2426
  }
785
2427
  return n;
786
2428
  };
787
- const links = dropBackEdges(input.links.filter((l) => l.value > 0 && l.source !== l.target));
2429
+ const links = dropBackEdges(usable);
788
2430
  for (const l of links) {
789
2431
  node(l.source);
790
2432
  node(l.target);
@@ -842,6 +2484,12 @@ function layoutSankey(input) {
842
2484
  fill: s.color,
843
2485
  opacity: 0.4,
844
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
+ },
845
2493
  });
846
2494
  }
847
2495
  for (const n of nodes.values()) {
@@ -854,6 +2502,7 @@ function layoutSankey(input) {
854
2502
  height: px(n.height),
855
2503
  fill: n.color,
856
2504
  title: `${n.name}: ${formatValue(n.value)}`,
2505
+ item: { label: `${n.name}: ${formatValue(n.value)}`, name: n.name, value: n.value },
857
2506
  });
858
2507
  const last = n.depth === depths;
859
2508
  shapes.push(label(px(last ? n.x - 6 : n.x + NODE_WIDTH + 6), px(n.y + n.height / 2), n.name, {
@@ -892,7 +2541,7 @@ function dropBackEdges(links) {
892
2541
  * This file is part of the SkandaDX organization.
893
2542
  * Licensed under the MIT License. See LICENSE in the repository root.
894
2543
  */
895
- 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);
896
2545
  /** Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles kept as close to square as possible. */
897
2546
  function squarify(items, x, y, w, h, out) {
898
2547
  if (items.length === 0)
@@ -938,10 +2587,14 @@ function squarify(items, x, y, w, h, out) {
938
2587
  }
939
2588
  }
940
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);
941
2594
  const { width, height } = size(input);
942
2595
  const top = legendTop(input);
943
2596
  const shapes = [];
944
- 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) }));
945
2598
  // Branches are outlined and their children tiled inside a 2px inset; leaves are filled.
946
2599
  const tile = (nodes, x, y, w, h, inherited) => {
947
2600
  const total = nodes.reduce((s, n) => s + Math.max(0, nodeValue$1(n)), 0);
@@ -949,10 +2602,10 @@ function layoutTreemap(input) {
949
2602
  .map((node, i) => ({
950
2603
  node,
951
2604
  value: nodeValue$1(node),
952
- color: node.color ?? inherited ?? seriesColor(i),
2605
+ color: node.color ?? inherited ?? colorOf(input, i),
953
2606
  area: total > 0 ? (Math.max(0, nodeValue$1(node)) / total) * w * h : 0,
954
2607
  }))
955
- .filter((t) => t.area > 0)
2608
+ .filter((t) => t.area > 0 && !(inherited === undefined && isHidden(input, t.node.name)))
956
2609
  .sort((a, b) => b.area - a.area);
957
2610
  squarify(tiles, x, y, w, h, (t, [tx, ty, tw, th]) => {
958
2611
  const title = `${t.node.name}: ${formatValue(t.value)}`;
@@ -968,6 +2621,7 @@ function layoutTreemap(input) {
968
2621
  opacity: branch ? 0.15 : undefined,
969
2622
  stroke: 'var(--skdx-color-surface-default)',
970
2623
  title,
2624
+ item: { label: title, name: t.node.name, value: t.value },
971
2625
  });
972
2626
  if (branch)
973
2627
  tile(t.node.children, tx + 2, ty + 2, Math.max(0, tw - 4), Math.max(0, th - 4), t.color);
@@ -979,10 +2633,8 @@ function layoutTreemap(input) {
979
2633
  });
980
2634
  };
981
2635
  tile(input.data, 0, top, width, height - top, undefined);
982
- return {
983
- ...canvas(input, width, height, legend),
984
- shapes: [...shapes, ...canvas(input, width, height, legend).shapes],
985
- };
2636
+ const c = canvas(input, width, height, legend);
2637
+ return { ...c, shapes: [...shapes, ...c.shapes] };
986
2638
  }
987
2639
 
988
2640
  /*
@@ -990,52 +2642,226 @@ function layoutTreemap(input) {
990
2642
  * This file is part of the SkandaDX organization.
991
2643
  * Licensed under the MIT License. See LICENSE in the repository root.
992
2644
  */
993
- const UP$1 = 'var(--skdx-color-success-500)';
994
- 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. */
995
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));
996
2664
  const { width, height } = size(input);
997
- const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
998
- const y = axis(extent(input.data.flatMap((c) => [c.low, c.high]), false), [plot.y + plot.height, plot.y], input.scale === 'log');
999
- 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));
1000
2676
  const legend = [
1001
2677
  { name: 'Up', color: UP$1 },
1002
2678
  { name: 'Down', color: DOWN$1 },
1003
2679
  ];
1004
- const c = canvas(input, width, height, legend);
2680
+ const c0 = canvas(input, width, height, legend);
1005
2681
  const frame = {
1006
- ...c,
2682
+ ...c0,
1007
2683
  plot,
1008
- 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),
1009
2685
  yTicks: y.ticks,
1010
2686
  baseline: plot.y + plot.height,
1011
2687
  };
1012
2688
  const shapes = frameShapes(frame);
1013
- input.data.forEach((c, i) => {
1014
- const color = c.close >= c.open ? UP$1 : DOWN$1;
1015
- 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
+ ]);
1016
2716
  const top = y.scale(Math.max(c.open, c.close));
1017
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
+ };
1018
2822
  shapes.push({
1019
- kind: 'line',
1020
- part: 'wick',
1021
- x1: px(x.center(i)),
1022
- x2: px(x.center(i)),
1023
- y1: px(y.scale(c.high)),
1024
- y2: px(y.scale(c.low)),
1025
- stroke: color,
1026
- 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,
1027
2836
  }, {
1028
2837
  kind: 'rect',
1029
- part: 'candle',
1030
- x: px(x.start(i)),
1031
- y: px(top),
1032
- width: px(x.bandwidth),
1033
- height: px(Math.max(1, bottom - top)),
1034
- fill: color,
1035
- 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,
1036
2861
  });
1037
- });
1038
- return { ...c, shapes: [...shapes, ...c.shapes] };
2862
+ hit.brush = { box, window };
2863
+ }
2864
+ return { ...c0, shapes: [...shapes, ...c0.shapes], hit };
1039
2865
  }
1040
2866
 
1041
2867
  /*
@@ -1046,11 +2872,16 @@ function layoutCandlestick(input) {
1046
2872
  const UP = 'var(--skdx-color-success-500)';
1047
2873
  const DOWN = 'var(--skdx-color-danger-500)';
1048
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);
1049
2880
  const { width, height } = size(input);
1050
2881
  const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
1051
2882
  const steps = [];
1052
2883
  let running = 0;
1053
- for (const d of input.data) {
2884
+ for (const d of data) {
1054
2885
  const from = running;
1055
2886
  running += d.value;
1056
2887
  steps.push({
@@ -1066,11 +2897,11 @@ function layoutWaterfall(input) {
1066
2897
  name: input.total,
1067
2898
  from: 0,
1068
2899
  to: running,
1069
- color: seriesColor(0),
2900
+ color: colorOf(input, 0),
1070
2901
  title: `${input.total}: ${formatValue(running)}`,
1071
2902
  });
1072
2903
  const totals = steps.flatMap((s) => [s.from, s.to]);
1073
- 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);
1074
2905
  const x = bandScale(steps.length, [plot.x, plot.x + plot.width]);
1075
2906
  const c = canvas(input, width, height, [
1076
2907
  { name: 'Increase', color: UP },
@@ -1079,7 +2910,7 @@ function layoutWaterfall(input) {
1079
2910
  const shapes = frameShapes({
1080
2911
  ...c,
1081
2912
  plot,
1082
- 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),
1083
2914
  yTicks: y.ticks,
1084
2915
  baseline: y.zero,
1085
2916
  });
@@ -1095,6 +2926,7 @@ function layoutWaterfall(input) {
1095
2926
  height: px(Math.max(1, Math.abs(y1 - y0))),
1096
2927
  fill: s.color,
1097
2928
  title: s.title,
2929
+ item: { label: s.title, name: s.name, index: i, value: s.to - s.from },
1098
2930
  });
1099
2931
  const next = steps[i + 1];
1100
2932
  if (next)
@@ -1117,24 +2949,30 @@ function layoutWaterfall(input) {
1117
2949
  * This file is part of the SkandaDX organization.
1118
2950
  * Licensed under the MIT License. See LICENSE in the repository root.
1119
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
+ */
1120
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;
1121
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;
1122
2964
  const plot = plotBox(width, height, {
1123
2965
  top: 8 + legendTop(input),
1124
2966
  right: 16,
1125
2967
  bottom: 32,
1126
- left: 112,
2968
+ left: Math.min(width / 2, Math.max(64, widest + 16)),
1127
2969
  });
1128
- const format = input.format ?? formatValue;
1129
- const times = input.tasks.flatMap((t) => [t.start, t.end]);
1130
- const x = axis(times.length ? [Math.min(...times), Math.max(...times)] : [0, 0], [
1131
- plot.x,
1132
- plot.x + plot.width,
1133
- ]);
1134
- 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]);
1135
2974
  const shapes = [];
1136
- const ticks = x.ticks.map((t) => ({ ...t, label: format(Number(t.label.replace(/,/g, ''))) }));
1137
- for (const t of ticks) {
2975
+ for (const t of thinTicks(x.ticks, plot.width)) {
1138
2976
  shapes.push({
1139
2977
  kind: 'line',
1140
2978
  part: 'grid',
@@ -1146,26 +2984,37 @@ function layoutGantt(input) {
1146
2984
  });
1147
2985
  shapes.push(label(t.pos, plot.y + plot.height + 20, t.label, { part: 'x-label', anchor: 'middle' }));
1148
2986
  }
1149
- 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);
1150
3001
  const x0 = x.scale(Math.min(t.start, t.end));
1151
3002
  const x1 = x.scale(Math.max(t.start, t.end));
1152
- shapes.push(label(plot.x - 8, px(rows.center(i)), t.name, {
1153
- part: 'y-label',
1154
- anchor: 'end',
1155
- baseline: 'middle',
1156
- }));
3003
+ const title = `${t.name}: ${format(t.start)} – ${format(t.end)}`;
1157
3004
  shapes.push({
1158
3005
  kind: 'rect',
1159
3006
  part: 'bar',
1160
3007
  x: px(x0),
1161
- y: px(rows.start(i)),
3008
+ y: px(rows.start(row)),
1162
3009
  width: px(Math.max(1, x1 - x0)),
1163
3010
  height: px(rows.bandwidth),
1164
- fill: seriesColor(i, t.color),
1165
- 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 },
1166
3015
  });
1167
3016
  });
1168
- 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) })));
1169
3018
  return { ...c, shapes: [...shapes, ...c.shapes] };
1170
3019
  }
1171
3020
 
@@ -1187,7 +3036,7 @@ const quantile = (sorted, q) => {
1187
3036
  };
1188
3037
  /** Five-number summary with Tukey fences: whiskers reach the last sample within 1.5 IQR. */
1189
3038
  function boxStats(data) {
1190
- const sorted = [...data].sort((a, b) => a - b);
3039
+ const sorted = finite(data).sort((a, b) => a - b);
1191
3040
  const q1 = quantile(sorted, 0.25);
1192
3041
  const median = quantile(sorted, 0.5);
1193
3042
  const q3 = quantile(sorted, 0.75);
@@ -1203,21 +3052,27 @@ function boxStats(data) {
1203
3052
  };
1204
3053
  }
1205
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);
1206
3059
  const { width, height } = size(input);
1207
3060
  const plot = plotBox(width, height, { ...MARGIN$1, top: MARGIN$1.top + legendTop(input) });
1208
- 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);
1209
3062
  const x = bandScale(input.series.length, [plot.x, plot.x + plot.width]);
1210
- 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) })));
1211
3064
  const shapes = frameShapes({
1212
3065
  ...c,
1213
3066
  plot,
1214
- 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),
1215
3068
  yTicks: y.ticks,
1216
3069
  baseline: plot.y + plot.height,
1217
3070
  });
1218
3071
  input.series.forEach((s, i) => {
3072
+ if (isHidden(input, s.name) || finite(s.data).length === 0)
3073
+ return;
1219
3074
  const st = boxStats(s.data);
1220
- const color = seriesColor(i, s.color);
3075
+ const color = colorOf(input, i, s.color);
1221
3076
  const cx = px(x.center(i));
1222
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)}`;
1223
3078
  const boxW = x.bandwidth * 0.6;
@@ -1242,6 +3097,7 @@ function layoutBoxplot(input) {
1242
3097
  stroke: color,
1243
3098
  opacity: 0.5,
1244
3099
  title,
3100
+ item: { label: title, series: s.name, index: i, value: st.median },
1245
3101
  }, {
1246
3102
  kind: 'line',
1247
3103
  part: 'median',
@@ -1270,9 +3126,13 @@ function layoutBoxplot(input) {
1270
3126
  * Licensed under the MIT License. See LICENSE in the repository root.
1271
3127
  */
1272
3128
  const TAU$1 = 2 * Math.PI;
1273
- 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);
1274
3130
  const depthOf = (nodes) => nodes.length === 0 ? 0 : 1 + Math.max(...nodes.map((n) => depthOf(n.children ?? [])));
1275
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);
1276
3136
  const { width, height } = size(input, 300, 300);
1277
3137
  const top = legendTop(input);
1278
3138
  const cx = width / 2;
@@ -1283,9 +3143,9 @@ function layoutSunburst(input) {
1283
3143
  const total = nodes.reduce((s, n) => s + Math.max(0, nodeValue(n)), 0);
1284
3144
  let angle = a0;
1285
3145
  nodes.forEach((n, i) => {
1286
- const value = Math.max(0, nodeValue(n));
3146
+ const value = depth === 0 && isHidden(input, n.name) ? 0 : Math.max(0, nodeValue(n));
1287
3147
  const sweep = total > 0 ? (value / total) * span : 0;
1288
- const fill = n.color ?? color ?? seriesColor(i);
3148
+ const fill = n.color ?? color ?? colorOf(input, i);
1289
3149
  if (sweep > 0) {
1290
3150
  shapes.push({
1291
3151
  kind: 'path',
@@ -1295,6 +3155,7 @@ function layoutSunburst(input) {
1295
3155
  opacity: 1 - depth * 0.2,
1296
3156
  stroke: 'var(--skdx-color-surface-default)',
1297
3157
  title: `${n.name}: ${formatValue(value)}`,
3158
+ item: { label: `${n.name}: ${formatValue(value)}`, name: n.name, value },
1298
3159
  });
1299
3160
  walk(n.children ?? [], depth + 1, angle, sweep, fill);
1300
3161
  }
@@ -1302,7 +3163,7 @@ function layoutSunburst(input) {
1302
3163
  });
1303
3164
  };
1304
3165
  walk(input.data, 0, 0, TAU$1, undefined);
1305
- 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) })));
1306
3167
  return { ...c, shapes: [...shapes, ...c.shapes] };
1307
3168
  }
1308
3169
 
@@ -1315,6 +3176,10 @@ const TAU = 2 * Math.PI;
1315
3176
  const PAD$1 = 0.04;
1316
3177
  const RING = 12;
1317
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);
1318
3183
  const { width, height } = size(input, 300, 300);
1319
3184
  const top = legendTop(input);
1320
3185
  const cx = width / 2;
@@ -1322,7 +3187,11 @@ function layoutChord(input) {
1322
3187
  const r = Math.max(0, Math.min(width, height - top) / 2 - 36);
1323
3188
  const r0 = Math.max(0, r - RING);
1324
3189
  const n = input.names.length;
1325
- 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
+ };
1326
3195
  const rowSum = input.names.map((_, i) => input.names.reduce((s, _n, j) => s + cell(i, j), 0));
1327
3196
  const total = rowSum.reduce((s, v) => s + v, 0);
1328
3197
  const unit = total > 0 ? (TAU - PAD$1 * n) / total : 0;
@@ -1358,7 +3227,7 @@ function layoutChord(input) {
1358
3227
  kind: 'path',
1359
3228
  part: 'chord',
1360
3229
  d,
1361
- fill: seriesColor(from),
3230
+ fill: colorOf(input, from),
1362
3231
  opacity: 0.5,
1363
3232
  title: i === j
1364
3233
  ? `${input.names[i]}: ${formatValue(out)}`
@@ -1373,8 +3242,14 @@ function layoutChord(input) {
1373
3242
  kind: 'path',
1374
3243
  part: 'group',
1375
3244
  d: arc(cx, cy, r, r0, g.a0, g.a1),
1376
- fill: seriesColor(i),
3245
+ fill: colorOf(input, i),
1377
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
+ },
1378
3253
  });
1379
3254
  const mid = (g.a0 + g.a1) / 2;
1380
3255
  const sin = Math.sin(mid);
@@ -1383,7 +3258,7 @@ function layoutChord(input) {
1383
3258
  baseline: 'middle',
1384
3259
  }));
1385
3260
  });
1386
- 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) })));
1387
3262
  return { ...c, shapes: [...shapes, ...c.shapes] };
1388
3263
  }
1389
3264
 
@@ -1398,23 +3273,22 @@ const rings = (f) => f.geometry.type === 'Polygon' ? f.geometry.coordinates : f.
1398
3273
  const mercatorY = (lat) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360));
1399
3274
  /** Projection fitted to the features' bounds (equirectangular by default, or Mercator); opacity follows the value. */
1400
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);
1401
3280
  const { width, height } = size(input);
1402
3281
  const top = legendTop(input);
1403
3282
  const toY = input.projection === 'mercator' ? mercatorY : (lat) => lat;
1404
3283
  const points = input.features.flatMap((f) => rings(f).flat());
1405
- const lons = points.map((p) => p[0]);
1406
- const lats = points.map((p) => toY(p[1]));
1407
- const [minLon, maxLon, minLat, maxLat] = points.length
1408
- ? [Math.min(...lons), Math.max(...lons), Math.min(...lats), Math.max(...lats)]
1409
- : [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];
1410
3286
  const scale = Math.min((width - 2 * PAD) / Math.max(1e-9, maxLon - minLon), (height - top - 2 * PAD) / Math.max(1e-9, maxLat - minLat));
1411
3287
  const ox = (width - (maxLon - minLon) * scale) / 2;
1412
3288
  const oy = top + (height - top - (maxLat - minLat) * scale) / 2;
1413
3289
  const project = ([lon, lat]) => `${px(ox + (lon - minLon) * scale)} ${px(oy + (maxLat - toY(lat)) * scale)}`;
1414
- const values = input.features.map((f) => f.value);
1415
- const min = Math.min(...values);
1416
- const max = Math.max(...values);
1417
- 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);
1418
3292
  const shapes = input.features.map((f) => ({
1419
3293
  kind: 'path',
1420
3294
  part: 'region',
@@ -1422,9 +3296,12 @@ function layoutMap(input) {
1422
3296
  .map((ring) => `M${ring.map(project).join('L')}Z`)
1423
3297
  .join(''),
1424
3298
  fill: color,
1425
- 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)),
1426
3302
  stroke: 'var(--skdx-color-surface-default)',
1427
3303
  title: `${f.name}: ${formatValue(f.value)}`,
3304
+ item: { label: `${f.name}: ${formatValue(f.value)}`, name: f.name, value: f.value },
1428
3305
  }));
1429
3306
  return { ...canvas(input, width, height), shapes };
1430
3307
  }
@@ -1440,7 +3317,7 @@ function layoutMap(input) {
1440
3317
  * This file is part of the SkandaDX organization.
1441
3318
  * Licensed under the MIT License. See LICENSE in the repository root.
1442
3319
  */
1443
- /** 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. */
1444
3321
  class SkdxChartShapesComponent {
1445
3322
  constructor() {
1446
3323
  this.shapes = input.required(/* @ts-ignore */
@@ -1453,11 +3330,16 @@ class SkdxChartShapesComponent {
1453
3330
  @case ('path') {
1454
3331
  <svg:path
1455
3332
  [attr.data-skdx-part]="s.part"
3333
+ [attr.data-skdx-i]="s.item ? $index : null"
1456
3334
  [attr.d]="s.d"
1457
3335
  [attr.fill]="s.fill ?? 'none'"
1458
3336
  [attr.stroke]="s.stroke"
1459
3337
  [attr.stroke-width]="s.strokeWidth"
3338
+ [attr.stroke-linecap]="s.strokeLinecap"
3339
+ [attr.stroke-linejoin]="s.strokeLinejoin"
3340
+ [attr.stroke-dasharray]="s.dash"
1460
3341
  [attr.opacity]="s.opacity"
3342
+ [attr.fill-opacity]="s.fillOpacity"
1461
3343
  >
1462
3344
  @if (s.title) {
1463
3345
  <svg:title>{{ s.title }}</svg:title>
@@ -1467,10 +3349,12 @@ class SkdxChartShapesComponent {
1467
3349
  @case ('rect') {
1468
3350
  <svg:rect
1469
3351
  [attr.data-skdx-part]="s.part"
3352
+ [attr.data-skdx-i]="s.item ? $index : null"
1470
3353
  [attr.x]="s.x"
1471
3354
  [attr.y]="s.y"
1472
3355
  [attr.width]="s.width"
1473
3356
  [attr.height]="s.height"
3357
+ [attr.rx]="s.rx"
1474
3358
  [attr.fill]="s.fill"
1475
3359
  [attr.stroke]="s.stroke"
1476
3360
  [attr.fill-opacity]="s.opacity"
@@ -1483,11 +3367,13 @@ class SkdxChartShapesComponent {
1483
3367
  @case ('line') {
1484
3368
  <svg:line
1485
3369
  [attr.data-skdx-part]="s.part"
3370
+ [attr.data-skdx-i]="s.item ? $index : null"
1486
3371
  [attr.x1]="s.x1"
1487
3372
  [attr.y1]="s.y1"
1488
3373
  [attr.x2]="s.x2"
1489
3374
  [attr.y2]="s.y2"
1490
3375
  [attr.stroke]="s.stroke"
3376
+ [attr.stroke-width]="s.strokeWidth"
1491
3377
  [attr.stroke-dasharray]="s.dash"
1492
3378
  >
1493
3379
  @if (s.title) {
@@ -1498,10 +3384,13 @@ class SkdxChartShapesComponent {
1498
3384
  @case ('circle') {
1499
3385
  <svg:circle
1500
3386
  [attr.data-skdx-part]="s.part"
3387
+ [attr.data-skdx-i]="s.item ? $index : null"
1501
3388
  [attr.cx]="s.cx"
1502
3389
  [attr.cy]="s.cy"
1503
3390
  [attr.r]="s.r"
1504
3391
  [attr.fill]="s.fill"
3392
+ [attr.stroke]="s.stroke"
3393
+ [attr.opacity]="s.opacity"
1505
3394
  >
1506
3395
  @if (s.title) {
1507
3396
  <svg:title>{{ s.title }}</svg:title>
@@ -1511,14 +3400,15 @@ class SkdxChartShapesComponent {
1511
3400
  @case ('text') {
1512
3401
  <svg:text
1513
3402
  [attr.data-skdx-part]="s.part"
3403
+ [attr.data-skdx-i]="s.item ? $index : null"
1514
3404
  [attr.x]="s.x"
1515
3405
  [attr.y]="s.y"
1516
3406
  [attr.text-anchor]="s.anchor"
1517
3407
  [attr.dominant-baseline]="s.baseline"
1518
3408
  [attr.fill]="s.fill"
1519
- >
1520
- {{ s.text }}
1521
- </svg:text>
3409
+ [attr.font-weight]="s.weight"
3410
+ [textContent]="s.text"
3411
+ ></svg:text>
1522
3412
  }
1523
3413
  }
1524
3414
  }
@@ -1536,11 +3426,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1536
3426
  @case ('path') {
1537
3427
  <svg:path
1538
3428
  [attr.data-skdx-part]="s.part"
3429
+ [attr.data-skdx-i]="s.item ? $index : null"
1539
3430
  [attr.d]="s.d"
1540
3431
  [attr.fill]="s.fill ?? 'none'"
1541
3432
  [attr.stroke]="s.stroke"
1542
3433
  [attr.stroke-width]="s.strokeWidth"
3434
+ [attr.stroke-linecap]="s.strokeLinecap"
3435
+ [attr.stroke-linejoin]="s.strokeLinejoin"
3436
+ [attr.stroke-dasharray]="s.dash"
1543
3437
  [attr.opacity]="s.opacity"
3438
+ [attr.fill-opacity]="s.fillOpacity"
1544
3439
  >
1545
3440
  @if (s.title) {
1546
3441
  <svg:title>{{ s.title }}</svg:title>
@@ -1550,10 +3445,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1550
3445
  @case ('rect') {
1551
3446
  <svg:rect
1552
3447
  [attr.data-skdx-part]="s.part"
3448
+ [attr.data-skdx-i]="s.item ? $index : null"
1553
3449
  [attr.x]="s.x"
1554
3450
  [attr.y]="s.y"
1555
3451
  [attr.width]="s.width"
1556
3452
  [attr.height]="s.height"
3453
+ [attr.rx]="s.rx"
1557
3454
  [attr.fill]="s.fill"
1558
3455
  [attr.stroke]="s.stroke"
1559
3456
  [attr.fill-opacity]="s.opacity"
@@ -1566,11 +3463,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1566
3463
  @case ('line') {
1567
3464
  <svg:line
1568
3465
  [attr.data-skdx-part]="s.part"
3466
+ [attr.data-skdx-i]="s.item ? $index : null"
1569
3467
  [attr.x1]="s.x1"
1570
3468
  [attr.y1]="s.y1"
1571
3469
  [attr.x2]="s.x2"
1572
3470
  [attr.y2]="s.y2"
1573
3471
  [attr.stroke]="s.stroke"
3472
+ [attr.stroke-width]="s.strokeWidth"
1574
3473
  [attr.stroke-dasharray]="s.dash"
1575
3474
  >
1576
3475
  @if (s.title) {
@@ -1581,10 +3480,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1581
3480
  @case ('circle') {
1582
3481
  <svg:circle
1583
3482
  [attr.data-skdx-part]="s.part"
3483
+ [attr.data-skdx-i]="s.item ? $index : null"
1584
3484
  [attr.cx]="s.cx"
1585
3485
  [attr.cy]="s.cy"
1586
3486
  [attr.r]="s.r"
1587
3487
  [attr.fill]="s.fill"
3488
+ [attr.stroke]="s.stroke"
3489
+ [attr.opacity]="s.opacity"
1588
3490
  >
1589
3491
  @if (s.title) {
1590
3492
  <svg:title>{{ s.title }}</svg:title>
@@ -1594,14 +3496,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1594
3496
  @case ('text') {
1595
3497
  <svg:text
1596
3498
  [attr.data-skdx-part]="s.part"
3499
+ [attr.data-skdx-i]="s.item ? $index : null"
1597
3500
  [attr.x]="s.x"
1598
3501
  [attr.y]="s.y"
1599
3502
  [attr.text-anchor]="s.anchor"
1600
3503
  [attr.dominant-baseline]="s.baseline"
1601
3504
  [attr.fill]="s.fill"
1602
- >
1603
- {{ s.text }}
1604
- </svg:text>
3505
+ [attr.font-weight]="s.weight"
3506
+ [textContent]="s.text"
3507
+ ></svg:text>
1605
3508
  }
1606
3509
  }
1607
3510
  }
@@ -1614,7 +3517,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1614
3517
  * This file is part of the SkandaDX organization.
1615
3518
  * Licensed under the MIT License. See LICENSE in the repository root.
1616
3519
  */
1617
- /** 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
+ */
1618
3524
  class SkdxChartRootComponent {
1619
3525
  constructor() {
1620
3526
  this.layout = input.required(/* @ts-ignore */
@@ -1623,38 +3529,219 @@ class SkdxChartRootComponent {
1623
3529
  ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
1624
3530
  this.label = input.required(/* @ts-ignore */
1625
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();
1626
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 */ []));
1627
3552
  this.tip = signal([], /* @ts-ignore */
1628
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);
1629
3590
  }
1630
3591
  onPointerMove(e) {
1631
- 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));
1632
3608
  return;
1633
- const { width, height } = this.layout();
1634
- 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);
1635
3613
  if (next.length > 0 || this.tip().length > 0)
1636
3614
  this.tip.set(next);
1637
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
+ }
1638
3696
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartRootComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1639
- 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: `
1640
3698
  <svg
3699
+ #svg
1641
3700
  xmlns="http://www.w3.org/2000/svg"
1642
3701
  [attr.viewBox]="'0 0 ' + layout().width + ' ' + layout().height"
1643
3702
  [attr.width]="layout().width"
1644
3703
  [attr.height]="layout().height"
1645
- role="img"
1646
- [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"
1647
3709
  [attr.data-skdx-chart]="kind()"
1648
3710
  [attr.font-family]="colors.fontFamily"
1649
3711
  [attr.font-size]="colors.fontSize"
1650
- 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()"
1651
3718
  (pointermove)="onPointerMove($event)"
1652
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)"
1653
3728
  >
1654
3729
  <title>{{ label() }}</title>
1655
- <ng-content></ng-content>
1656
- <svg:g skdxChartShapes [shapes]="layout().shapes"></svg:g>
1657
- <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>
1658
3745
  </svg>
1659
3746
  `, isInline: true, dependencies: [{ kind: "component", type: SkdxChartShapesComponent, selector: "g[skdxChartShapes]", inputs: ["shapes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1660
3747
  }
@@ -1664,181 +3751,61 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1664
3751
  selector: 'skdx-chart-root',
1665
3752
  standalone: true,
1666
3753
  changeDetection: ChangeDetectionStrategy.OnPush,
1667
- host: { style: 'display: inline-block' },
3754
+ host: { '[style.display]': "responsive() ? 'block' : 'inline-block'" },
1668
3755
  imports: [SkdxChartShapesComponent],
1669
3756
  template: `
1670
3757
  <svg
3758
+ #svg
1671
3759
  xmlns="http://www.w3.org/2000/svg"
1672
3760
  [attr.viewBox]="'0 0 ' + layout().width + ' ' + layout().height"
1673
3761
  [attr.width]="layout().width"
1674
3762
  [attr.height]="layout().height"
1675
- role="img"
1676
- [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"
1677
3768
  [attr.data-skdx-chart]="kind()"
1678
3769
  [attr.font-family]="colors.fontFamily"
1679
3770
  [attr.font-size]="colors.fontSize"
1680
- 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()"
1681
3777
  (pointermove)="onPointerMove($event)"
1682
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)"
1683
3787
  >
1684
3788
  <title>{{ label() }}</title>
1685
- <ng-content></ng-content>
1686
- <svg:g skdxChartShapes [shapes]="layout().shapes"></svg:g>
1687
- <svg:g skdxChartShapes [shapes]="tip()"></svg:g>
1688
- </svg>
1689
- `,
1690
- }]
1691
- }], 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 }] }] } });
1692
- /** The `<svg>` root, grid, axis and tick labels every axis chart shares; marks are projected. */
1693
- class SkdxChartFrameComponent {
1694
- constructor() {
1695
- this.layout = input.required(/* @ts-ignore */
1696
- ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
1697
- this.kind = input.required(/* @ts-ignore */
1698
- ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
1699
- this.label = input.required(/* @ts-ignore */
1700
- ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
1701
- this.colors = CHART_COLORS;
1702
- this.plot = computed(() => this.layout().plot, /* @ts-ignore */
1703
- ...(ngDevMode ? [{ debugName: "plot" }] : /* istanbul ignore next */ []));
1704
- this.right = computed(() => this.plot().x + this.plot().width, /* @ts-ignore */
1705
- ...(ngDevMode ? [{ debugName: "right" }] : /* istanbul ignore next */ []));
1706
- }
1707
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartFrameComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1708
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.4", type: SkdxChartFrameComponent, isStandalone: true, selector: "skdx-chart-frame", 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: `
1709
- <skdx-chart-root [layout]="layout()" [kind]="kind()" [label]="label()">
1710
- <svg:g data-skdx-part="grid" [attr.stroke]="colors.grid">
1711
- @for (t of layout().yTicks; track t.pos) {
1712
- <svg:line
1713
- [attr.x1]="plot().x"
1714
- [attr.x2]="right()"
1715
- [attr.y1]="t.pos"
1716
- [attr.y2]="t.pos"
1717
- ></svg:line>
1718
- }
1719
- </svg:g>
1720
- <svg:line
1721
- data-skdx-part="axis"
1722
- [attr.x1]="plot().x"
1723
- [attr.x2]="right()"
1724
- [attr.y1]="layout().baseline"
1725
- [attr.y2]="layout().baseline"
1726
- [attr.stroke]="colors.axis"
1727
- ></svg:line>
3789
+ @if (layout().description) {
3790
+ <svg:desc [attr.id]="descId()">{{ layout().description }}</svg:desc>
3791
+ }
1728
3792
  <svg:g
1729
- data-skdx-part="y-labels"
1730
- [attr.fill]="colors.label"
1731
- text-anchor="end"
1732
- dominant-baseline="middle"
1733
- >
1734
- @for (t of layout().yTicks; track t.pos) {
1735
- <svg:text [attr.x]="plot().x - 8" [attr.y]="t.pos">{{ t.label }}</svg:text>
1736
- }
1737
- </svg:g>
1738
- <svg:g data-skdx-part="x-labels" [attr.fill]="colors.label" text-anchor="middle">
1739
- @for (t of layout().xTicks; track $index) {
1740
- <svg:text [attr.x]="t.pos" [attr.y]="plot().y + plot().height + 20">
1741
- {{ t.label }}
1742
- </svg:text>
1743
- }
1744
- </svg:g>
1745
- <ng-content></ng-content>
1746
- </skdx-chart-root>
1747
- `, isInline: true, dependencies: [{ kind: "component", type: SkdxChartRootComponent, selector: "skdx-chart-root", inputs: ["layout", "kind", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1748
- }
1749
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartFrameComponent, decorators: [{
1750
- type: Component,
1751
- args: [{
1752
- selector: 'skdx-chart-frame',
1753
- standalone: true,
1754
- changeDetection: ChangeDetectionStrategy.OnPush,
1755
- host: { style: 'display: inline-block' },
1756
- imports: [SkdxChartRootComponent],
1757
- template: `
1758
- <skdx-chart-root [layout]="layout()" [kind]="kind()" [label]="label()">
1759
- <svg:g data-skdx-part="grid" [attr.stroke]="colors.grid">
1760
- @for (t of layout().yTicks; track t.pos) {
1761
- <svg:line
1762
- [attr.x1]="plot().x"
1763
- [attr.x2]="right()"
1764
- [attr.y1]="t.pos"
1765
- [attr.y2]="t.pos"
1766
- ></svg:line>
1767
- }
1768
- </svg:g>
1769
- <svg:line
1770
- data-skdx-part="axis"
1771
- [attr.x1]="plot().x"
1772
- [attr.x2]="right()"
1773
- [attr.y1]="layout().baseline"
1774
- [attr.y2]="layout().baseline"
1775
- [attr.stroke]="colors.axis"
1776
- ></svg:line>
3793
+ skdxChartShapes
3794
+ [shapes]="layout().shapes"
3795
+ ></svg:g>
1777
3796
  <svg:g
1778
- data-skdx-part="y-labels"
1779
- [attr.fill]="colors.label"
1780
- text-anchor="end"
1781
- dominant-baseline="middle"
1782
- >
1783
- @for (t of layout().yTicks; track t.pos) {
1784
- <svg:text [attr.x]="plot().x - 8" [attr.y]="t.pos">{{ t.label }}</svg:text>
1785
- }
1786
- </svg:g>
1787
- <svg:g data-skdx-part="x-labels" [attr.fill]="colors.label" text-anchor="middle">
1788
- @for (t of layout().xTicks; track $index) {
1789
- <svg:text [attr.x]="t.pos" [attr.y]="plot().y + plot().height + 20">
1790
- {{ t.label }}
1791
- </svg:text>
1792
- }
1793
- </svg:g>
1794
- <ng-content></ng-content>
1795
- </skdx-chart-root>
1796
- `,
1797
- }]
1798
- }], 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 }] }] } });
1799
- /** A dot per value with its native tooltip. */
1800
- class SkdxChartPointsComponent {
1801
- constructor() {
1802
- this.points = input.required(/* @ts-ignore */
1803
- ...(ngDevMode ? [{ debugName: "points" }] : /* istanbul ignore next */ []));
1804
- }
1805
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartPointsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1806
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.4", type: SkdxChartPointsComponent, isStandalone: true, selector: "g[skdxChartPoints]", inputs: { points: { classPropertyName: "points", publicName: "points", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1807
- @for (p of points(); track $index) {
1808
- <svg:circle
1809
- data-skdx-part="point"
1810
- [attr.cx]="p.x"
1811
- [attr.cy]="p.y"
1812
- r="3"
1813
- [attr.fill]="p.color"
1814
- >
1815
- <svg:title>{{ p.title }}</svg:title>
1816
- </svg:circle>
1817
- }
1818
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1819
- }
1820
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartPointsComponent, decorators: [{
1821
- type: Component,
1822
- args: [{
1823
- selector: 'g[skdxChartPoints]',
1824
- standalone: true,
1825
- changeDetection: ChangeDetectionStrategy.OnPush,
1826
- template: `
1827
- @for (p of points(); track $index) {
1828
- <svg:circle
1829
- data-skdx-part="point"
1830
- [attr.cx]="p.x"
1831
- [attr.cy]="p.y"
1832
- r="3"
1833
- [attr.fill]="p.color"
1834
- >
1835
- <svg:title>{{ p.title }}</svg:title>
1836
- </svg:circle>
1837
- }
3797
+ skdxChartShapes
3798
+ [shapes]="tip()"
3799
+ ></svg:g>
3800
+ <svg:g
3801
+ skdxChartShapes
3802
+ [shapes]="focusShapes()"
3803
+ ></svg:g>
3804
+ </svg>
1838
3805
  `,
1839
3806
  }]
1840
- }], propDecorators: { points: [{ type: i0.Input, args: [{ isSignal: true, alias: "points", required: true }] }] } });
1841
- /** Size and label inputs every chart takes. */
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. */
1842
3809
  class SkdxSizedBase {
1843
3810
  constructor() {
1844
3811
  this.width = input(/* @ts-ignore */
@@ -1854,92 +3821,164 @@ class SkdxSizedBase {
1854
3821
  /** Show a hover tooltip for the mark under the pointer. */
1855
3822
  this.tooltip = input(true, /* @ts-ignore */
1856
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]);
1857
3872
  }
1858
3873
  sized() {
1859
3874
  return {
1860
- width: this.width(),
3875
+ width: this.responsive() && this.measuredWidth() ? this.measuredWidth() : this.width(),
1861
3876
  height: this.height(),
3877
+ animate: this.animate(),
3878
+ responsive: this.responsive(),
1862
3879
  legend: this.legend(),
1863
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(),
1864
3890
  };
1865
3891
  }
1866
3892
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxSizedBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1867
- 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 }); }
1868
3894
  }
1869
3895
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxSizedBase, decorators: [{
1870
3896
  type: Directive
1871
- }], 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 }] }] } });
1872
- /** 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. */
1873
3899
  class SkdxChartBase extends SkdxSizedBase {
1874
3900
  constructor() {
1875
3901
  super(...arguments);
1876
3902
  this.series = input.required(/* @ts-ignore */
1877
3903
  ...(ngDevMode ? [{ debugName: "series" }] : /* istanbul ignore next */ []));
3904
+ /** X-axis labels; numbers and dates are written with `xAxis.format`. */
1878
3905
  this.categories = input(/* @ts-ignore */
1879
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 */ []));
1880
3950
  }
1881
3951
  input() {
1882
- 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
+ };
1883
3975
  }
1884
3976
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartBase, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
1885
- 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 }); }
1886
3978
  }
1887
3979
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartBase, decorators: [{
1888
3980
  type: Directive
1889
- }], propDecorators: { series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: false }] }] } });
1890
-
1891
- /*
1892
- * Copyright (c) 2026 SkandaDX
1893
- * This file is part of the SkandaDX organization.
1894
- * Licensed under the MIT License. See LICENSE in the repository root.
1895
- */
1896
- /** Line strokes plus a dot per value, shared by the line and area charts. */
1897
- class SkdxChartLinesComponent {
1898
- constructor() {
1899
- this.lines = input.required(/* @ts-ignore */
1900
- ...(ngDevMode ? [{ debugName: "lines" }] : /* istanbul ignore next */ []));
1901
- this.points = input.required(/* @ts-ignore */
1902
- ...(ngDevMode ? [{ debugName: "points" }] : /* istanbul ignore next */ []));
1903
- }
1904
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartLinesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1905
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.4", type: SkdxChartLinesComponent, isStandalone: true, selector: "g[skdxChartLines]", inputs: { lines: { classPropertyName: "lines", publicName: "lines", isSignal: true, isRequired: true, transformFunction: null }, points: { classPropertyName: "points", publicName: "points", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1906
- @for (l of lines(); track l.series) {
1907
- <svg:path
1908
- data-skdx-part="line"
1909
- [attr.d]="l.d"
1910
- fill="none"
1911
- [attr.stroke]="l.color"
1912
- stroke-width="2"
1913
- stroke-linejoin="round"
1914
- stroke-linecap="round"
1915
- ></svg:path>
1916
- }
1917
- <svg:g skdxChartPoints [points]="points()"></svg:g>
1918
- `, isInline: true, dependencies: [{ kind: "component", type: SkdxChartPointsComponent, selector: "g[skdxChartPoints]", inputs: ["points"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1919
- }
1920
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: SkdxChartLinesComponent, decorators: [{
1921
- type: Component,
1922
- args: [{
1923
- selector: 'g[skdxChartLines]',
1924
- standalone: true,
1925
- changeDetection: ChangeDetectionStrategy.OnPush,
1926
- imports: [SkdxChartPointsComponent],
1927
- template: `
1928
- @for (l of lines(); track l.series) {
1929
- <svg:path
1930
- data-skdx-part="line"
1931
- [attr.d]="l.d"
1932
- fill="none"
1933
- [attr.stroke]="l.color"
1934
- stroke-width="2"
1935
- stroke-linejoin="round"
1936
- stroke-linecap="round"
1937
- ></svg:path>
1938
- }
1939
- <svg:g skdxChartPoints [points]="points()"></svg:g>
1940
- `,
1941
- }]
1942
- }], propDecorators: { lines: [{ type: i0.Input, args: [{ isSignal: true, alias: "lines", required: true }] }], points: [{ type: i0.Input, args: [{ isSignal: true, alias: "points", required: true }] }] } });
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 }] }] } });
1943
3982
 
1944
3983
  /*
1945
3984
  * Copyright (c) 2026 SkandaDX
@@ -1951,5 +3990,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
1951
3990
  * Generated bundle index. Do not edit.
1952
3991
  */
1953
3992
 
1954
- export { CHART_COLORS, SERIES_COLORS, SkdxChartBase, SkdxChartFrameComponent, SkdxChartLinesComponent, SkdxChartPointsComponent, SkdxChartRootComponent, SkdxChartShapesComponent, SkdxSizedBase, boxStats, formatValue, hoverTooltip, layoutAreaChart, layoutBarChart, layoutBoxplot, layoutCandlestick, layoutChord, layoutFunnelChart, layoutGantt, layoutHeatmap, layoutLineChart, layoutMap, layoutPieChart, layoutRadarChart, layoutSankey, layoutScatterChart, layoutSparkline, layoutSunburst, layoutTreemap, layoutWaterfall, linearScale, logTicks, niceTicks, pathThrough, seriesColor, svgAttrs, tooltipShapes };
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 };
1955
3994
  //# sourceMappingURL=skdx-angular-charts-internal.mjs.map