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