@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.
- package/README.md +96 -41
- package/fesm2022/skdx-angular-charts-area.mjs +27 -33
- package/fesm2022/skdx-angular-charts-area.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-bar.mjs +30 -51
- package/fesm2022/skdx-angular-charts-bar.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-boxplot.mjs +26 -14
- package/fesm2022/skdx-angular-charts-boxplot.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-candlestick.mjs +49 -16
- package/fesm2022/skdx-angular-charts-candlestick.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-chord.mjs +26 -14
- package/fesm2022/skdx-angular-charts-chord.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-funnel.mjs +37 -48
- package/fesm2022/skdx-angular-charts-funnel.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-gantt.mjs +31 -19
- package/fesm2022/skdx-angular-charts-gantt.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-heatmap.mjs +54 -81
- package/fesm2022/skdx-angular-charts-heatmap.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-internal.mjs +2746 -707
- package/fesm2022/skdx-angular-charts-internal.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-line.mjs +26 -16
- package/fesm2022/skdx-angular-charts-line.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-map.mjs +35 -14
- package/fesm2022/skdx-angular-charts-map.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-pie.mjs +47 -25
- package/fesm2022/skdx-angular-charts-pie.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-radar.mjs +45 -81
- package/fesm2022/skdx-angular-charts-radar.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-radial.mjs +108 -0
- package/fesm2022/skdx-angular-charts-radial.mjs.map +1 -0
- package/fesm2022/skdx-angular-charts-sankey.mjs +26 -14
- package/fesm2022/skdx-angular-charts-sankey.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-scatter.mjs +49 -19
- package/fesm2022/skdx-angular-charts-scatter.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-sparkline.mjs +50 -60
- package/fesm2022/skdx-angular-charts-sparkline.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-sunburst.mjs +26 -14
- package/fesm2022/skdx-angular-charts-sunburst.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-treemap.mjs +26 -14
- package/fesm2022/skdx-angular-charts-treemap.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts-waterfall.mjs +26 -14
- package/fesm2022/skdx-angular-charts-waterfall.mjs.map +1 -1
- package/fesm2022/skdx-angular-charts.mjs +2 -0
- package/fesm2022/skdx-angular-charts.mjs.map +1 -1
- package/package.json +7 -12
- package/types/skdx-angular-charts-area.d.ts +2 -2
- package/types/skdx-angular-charts-bar.d.ts +5 -13
- package/types/skdx-angular-charts-boxplot.d.ts +2 -2
- package/types/skdx-angular-charts-candlestick.d.ts +10 -4
- package/types/skdx-angular-charts-chord.d.ts +2 -2
- package/types/skdx-angular-charts-funnel.d.ts +5 -3
- package/types/skdx-angular-charts-gantt.d.ts +6 -6
- package/types/skdx-angular-charts-heatmap.d.ts +16 -15
- package/types/skdx-angular-charts-internal.d.ts +595 -232
- package/types/skdx-angular-charts-line.d.ts +2 -2
- package/types/skdx-angular-charts-map.d.ts +4 -3
- package/types/skdx-angular-charts-pie.d.ts +11 -3
- package/types/skdx-angular-charts-radar.d.ts +8 -10
- package/types/skdx-angular-charts-radial.d.ts +34 -0
- package/types/skdx-angular-charts-sankey.d.ts +2 -2
- package/types/skdx-angular-charts-scatter.d.ts +11 -4
- package/types/skdx-angular-charts-sparkline.d.ts +13 -5
- package/types/skdx-angular-charts-sunburst.d.ts +2 -2
- package/types/skdx-angular-charts-treemap.d.ts +4 -4
- package/types/skdx-angular-charts-waterfall.d.ts +2 -2
- 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,
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
184
|
-
const
|
|
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 +
|
|
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
|
-
|
|
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,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
239
|
-
|
|
445
|
+
format,
|
|
446
|
+
ticks,
|
|
240
447
|
zero: px(scale(Math.min(Math.max(0, domain[0]), domain[1]))),
|
|
241
448
|
};
|
|
242
449
|
}
|
|
243
|
-
/**
|
|
244
|
-
function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
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}: ${
|
|
459
|
+
const markTitle = (series, category, value, format) => `${series}, ${category}: ${format(value)}`;
|
|
280
460
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
const {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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, {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
456
|
-
|
|
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
|
|
459
|
-
|
|
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 =
|
|
462
|
-
const percent = 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 *
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
const
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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,
|
|
537
|
-
const max = ticks[ticks.length - 1];
|
|
538
|
-
const seriesMax = input.series.map((s) => Math.max(0,
|
|
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 =
|
|
558
|
-
|
|
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
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
...
|
|
572
|
-
|
|
573
|
-
.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
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
|
-
*
|
|
660
|
-
*
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
/**
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
983
|
-
|
|
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 =
|
|
994
|
-
const DOWN$1 =
|
|
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
|
|
998
|
-
const
|
|
999
|
-
|
|
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
|
|
2680
|
+
const c0 = canvas(input, width, height, legend);
|
|
1005
2681
|
const frame = {
|
|
1006
|
-
...
|
|
2682
|
+
...c0,
|
|
1007
2683
|
plot,
|
|
1008
|
-
xTicks:
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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: '
|
|
1020
|
-
part: '
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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: '
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
2968
|
+
left: Math.min(width / 2, Math.max(64, widest + 16)),
|
|
1127
2969
|
});
|
|
1128
|
-
const format = input.format
|
|
1129
|
-
const
|
|
1130
|
-
const x = axis(
|
|
1131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3008
|
+
y: px(rows.start(row)),
|
|
1162
3009
|
width: px(Math.max(1, x1 - x0)),
|
|
1163
3010
|
height: px(rows.bandwidth),
|
|
1164
|
-
fill:
|
|
1165
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
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 ??
|
|
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
|
|
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) =>
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
1406
|
-
const
|
|
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
|
|
1415
|
-
const
|
|
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
|
|
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
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1634
|
-
|
|
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.
|
|
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-
|
|
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
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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: '
|
|
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-
|
|
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
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
1730
|
-
[
|
|
1731
|
-
|
|
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
|
-
|
|
1779
|
-
[
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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: {
|
|
1841
|
-
/** Size and
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|