@svgrid/grid 3.0.2 → 3.0.4
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 +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
- package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
- package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
- package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
- package/dist/cdn/chart-CKJRCPOk.js +0 -1652
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { round, DEFAULT_PALETTE, axisScale, pickContrastText, sampleGradient } from './chart-scale.js';
|
|
2
|
+
// ---- Radar --------------------------------------------------------
|
|
3
|
+
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
4
|
+
// contributes a polygon connecting its values across the spokes. All
|
|
5
|
+
// series share the same scale (max across every value). Concentric
|
|
6
|
+
// ring count derived from data, capped at 5 for legibility.
|
|
7
|
+
/** @internal Lay out the radar family. Called by buildChart. */
|
|
8
|
+
export function layoutRadar(ctx) {
|
|
9
|
+
const { spec, series, width, height, empty, frame } = ctx;
|
|
10
|
+
if (!series.length || !spec.categories.length)
|
|
11
|
+
return { ...empty };
|
|
12
|
+
const padL = 30, padR = 30, padT = 24 + frame.top, padB = 24 + frame.bottom;
|
|
13
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
14
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
15
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
16
|
+
const cx = padL + plotW / 2;
|
|
17
|
+
const cy = padT + plotH / 2;
|
|
18
|
+
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20);
|
|
19
|
+
const axes = spec.categories;
|
|
20
|
+
const k = axes.length;
|
|
21
|
+
let vMax = 0;
|
|
22
|
+
for (const s of series)
|
|
23
|
+
for (const v of s.values) {
|
|
24
|
+
if (Number.isFinite(v) && v > vMax)
|
|
25
|
+
vMax = v;
|
|
26
|
+
}
|
|
27
|
+
if (vMax === 0)
|
|
28
|
+
vMax = 1;
|
|
29
|
+
// The rim is the data maximum unless `yAxis.min` / `max` pin it, the way
|
|
30
|
+
// every value axis is pinned: scores out of 100 then fill the dial to the
|
|
31
|
+
// same rim on every chart, and a point past the rim sits on it.
|
|
32
|
+
const vMin = spec.yAxis?.min ?? 0;
|
|
33
|
+
const vTop = spec.yAxis?.max ?? Math.max(vMax, vMin + 1);
|
|
34
|
+
const span = vTop > vMin ? vTop - vMin : 1;
|
|
35
|
+
const ringCount = 5;
|
|
36
|
+
const ringValues = Array.from({ length: ringCount }, (_, i) => vMin + ((i + 1) / ringCount) * span);
|
|
37
|
+
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
38
|
+
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
39
|
+
const angleAt = (i) => -Math.PI / 2 + (i / k) * Math.PI * 2;
|
|
40
|
+
const pointAt = (i, v) => {
|
|
41
|
+
const t = Math.max(0, Math.min(1, (v - vMin) / span));
|
|
42
|
+
const a = angleAt(i);
|
|
43
|
+
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) };
|
|
44
|
+
};
|
|
45
|
+
const radarAxes = axes.map((label, i) => {
|
|
46
|
+
const p = pointAt(i, vTop);
|
|
47
|
+
return { label, x: p.x, y: p.y };
|
|
48
|
+
});
|
|
49
|
+
const radarSeriesGeo = series.map((s, si) => {
|
|
50
|
+
const pts = s.values.map((v, i) => {
|
|
51
|
+
const safe = Number.isFinite(v) ? v : vMin;
|
|
52
|
+
const p = pointAt(i, safe);
|
|
53
|
+
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' };
|
|
54
|
+
});
|
|
55
|
+
const path = pts.length
|
|
56
|
+
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
57
|
+
: '';
|
|
58
|
+
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
59
|
+
return { label: s.label, color: s.color ?? palette[si % palette.length], path, points: pts };
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
...empty,
|
|
63
|
+
plot,
|
|
64
|
+
radarRings: ringValues,
|
|
65
|
+
radarAxes,
|
|
66
|
+
radarSeries: radarSeriesGeo,
|
|
67
|
+
radarCenter: { cx, cy, r },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ---- Gauge --------------------------------------------------------
|
|
71
|
+
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
72
|
+
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
73
|
+
/** @internal Lay out the gauge family. Called by buildChart. */
|
|
74
|
+
export function layoutGauge(ctx) {
|
|
75
|
+
const { spec, width, height, empty, frame } = ctx;
|
|
76
|
+
const min = spec.gaugeMin ?? 0;
|
|
77
|
+
const max = spec.gaugeMax ?? 100;
|
|
78
|
+
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0));
|
|
79
|
+
const target = spec.gaugeTarget;
|
|
80
|
+
const cx = width / 2;
|
|
81
|
+
const innerH = Math.max(1, height - frame.top - frame.bottom);
|
|
82
|
+
const cy = frame.top + innerH * 0.78;
|
|
83
|
+
const r = Math.min(width * 0.42, innerH * 0.65);
|
|
84
|
+
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
85
|
+
const A0 = Math.PI;
|
|
86
|
+
const A1 = 2 * Math.PI;
|
|
87
|
+
const angleAt = (v) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0);
|
|
88
|
+
const arc = (a0, a1, radius) => {
|
|
89
|
+
const x1 = cx + radius * Math.cos(a0);
|
|
90
|
+
const y1 = cy + radius * Math.sin(a0);
|
|
91
|
+
const x2 = cx + radius * Math.cos(a1);
|
|
92
|
+
const y2 = cy + radius * Math.sin(a1);
|
|
93
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
94
|
+
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`;
|
|
95
|
+
};
|
|
96
|
+
const trackPath = arc(A0, A1, r);
|
|
97
|
+
const valuePath = arc(A0, angleAt(value), r);
|
|
98
|
+
// Bands sit on their own inner ring, well clear of the value arc's 16px
|
|
99
|
+
// stroke at `r`. They are context, not the reading: a band covering most
|
|
100
|
+
// of the scale (an error-rate dial where anything above 0.45 is red) used
|
|
101
|
+
// to out-shout the value arc completely, so the dial looked pegged at
|
|
102
|
+
// maximum when the actual value was 9 percent.
|
|
103
|
+
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
104
|
+
path: arc(angleAt(band.from), angleAt(band.to), r - 16),
|
|
105
|
+
color: band.color, from: band.from, to: band.to,
|
|
106
|
+
}));
|
|
107
|
+
let targetPx = null;
|
|
108
|
+
if (target != null && Number.isFinite(target)) {
|
|
109
|
+
const a = angleAt(Math.max(min, Math.min(max, target)));
|
|
110
|
+
const inner = r - 12;
|
|
111
|
+
const outer = r + 4;
|
|
112
|
+
targetPx = {
|
|
113
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
114
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
118
|
+
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
119
|
+
const ticks = [];
|
|
120
|
+
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR;
|
|
121
|
+
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
122
|
+
const a = A0 + (i / TICK_TOTAL) * (A1 - A0);
|
|
123
|
+
const major = i % TICK_MINOR === 0;
|
|
124
|
+
const inner = r + 9;
|
|
125
|
+
const outer = r + (major ? 17 : 13);
|
|
126
|
+
ticks.push({
|
|
127
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
128
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
129
|
+
major,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
133
|
+
// tail) pivoting on a center hub.
|
|
134
|
+
//
|
|
135
|
+
// The proportions matter more than they look, and the counterweight was the
|
|
136
|
+
// whole problem. A tail sticking out past the hub is a short, solid triangle;
|
|
137
|
+
// the pointer is a 130px taper that thins to nothing. The compact shape wins
|
|
138
|
+
// the eye, so on a near-full dial the needle read as an arrow pointing at the
|
|
139
|
+
// MINIMUM. Keeping the tail inside the hub radius removes the competing
|
|
140
|
+
// point without giving up the pivot, and the shoulders sit outside the hub so
|
|
141
|
+
// the needle actually has a visible base to taper from.
|
|
142
|
+
const aV = angleAt(value);
|
|
143
|
+
const tipR = r - 14, tailR = 5, baseR = 9;
|
|
144
|
+
const aPerp = aV + Math.PI / 2;
|
|
145
|
+
const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
|
|
146
|
+
const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
|
|
147
|
+
// Color the value arc by the band the value currently sits in.
|
|
148
|
+
// First match wins, and bands are half-open [from, to). Bands normally
|
|
149
|
+
// share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
|
|
150
|
+
// an inclusive `to` plus last-match-wins, a value sitting exactly ON a
|
|
151
|
+
// boundary took the colour of the band ABOVE it. An error rate of 0.45
|
|
152
|
+
// against a 0.45 amber ceiling read as red.
|
|
153
|
+
let valueColor = null;
|
|
154
|
+
const bands = spec.gaugeRanges ?? [];
|
|
155
|
+
for (const band of bands) {
|
|
156
|
+
if (value >= band.from && value < band.to) {
|
|
157
|
+
valueColor = band.color;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// The very top of the scale belongs to the last band that reaches it,
|
|
162
|
+
// which the half-open test above would otherwise exclude.
|
|
163
|
+
if (valueColor == null) {
|
|
164
|
+
for (const band of bands)
|
|
165
|
+
if (value >= band.from && value <= band.to)
|
|
166
|
+
valueColor = band.color;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...empty,
|
|
170
|
+
plot: { x: 0, y: 0, w: width, h: height },
|
|
171
|
+
gauge: {
|
|
172
|
+
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
173
|
+
ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
|
|
174
|
+
minLabel: { x: cx - r, y: cy + 20 },
|
|
175
|
+
maxLabel: { x: cx + r, y: cy + 20 },
|
|
176
|
+
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** @internal Lay out the pie family. Called by buildChart. */
|
|
181
|
+
export function layoutPie(ctx) {
|
|
182
|
+
const { spec, series, empty, width, height, palette, frame } = ctx;
|
|
183
|
+
const s = series[0];
|
|
184
|
+
if (!s)
|
|
185
|
+
return empty;
|
|
186
|
+
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1;
|
|
187
|
+
const innerH = Math.max(1, height - frame.top - frame.bottom);
|
|
188
|
+
const cy = frame.top + innerH / 2;
|
|
189
|
+
// Callout labels need a margin round the pie for the leaders and the text:
|
|
190
|
+
// 34px above and below, and on each side the leader's horizontal run plus
|
|
191
|
+
// the widest label the callouts will write THERE (the label truncated to 18
|
|
192
|
+
// characters, a space and the percentage, at about 6.3px a character). The
|
|
193
|
+
// side is the slice's mid angle, which depends on the values alone. Sized
|
|
194
|
+
// by the widest label on either side, a pie whose small slices all sat on
|
|
195
|
+
// the left kept the same room on the right for a "Chrome 62%" and drew at
|
|
196
|
+
// half the radius the pane had; the pie now sits centred in what the two
|
|
197
|
+
// gutters leave. Without the text in the sum at all, a pie in a narrow
|
|
198
|
+
// pane pushed its labels past the edge and the reader saw "%" and a letter.
|
|
199
|
+
const callouts = spec.dataLabels?.placement === 'outside' && spec.dataLabels.show !== false;
|
|
200
|
+
let leftW = 0;
|
|
201
|
+
let rightW = 0;
|
|
202
|
+
if (callouts) {
|
|
203
|
+
let a = -Math.PI / 2;
|
|
204
|
+
spec.categories.forEach((c, i) => {
|
|
205
|
+
const frac = Math.max(0, s.values[i] ?? 0) / total;
|
|
206
|
+
const mid = a + (frac * Math.PI * 2) / 2;
|
|
207
|
+
a += frac * Math.PI * 2;
|
|
208
|
+
if (frac < 0.015)
|
|
209
|
+
return;
|
|
210
|
+
const w = (Math.min(c.length, 18) + 4) * 6.3;
|
|
211
|
+
if (Math.cos(mid) >= 0)
|
|
212
|
+
rightW = Math.max(rightW, w);
|
|
213
|
+
else
|
|
214
|
+
leftW = Math.max(leftW, w);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const leftGutter = callouts ? 22 + 4 + leftW : 10;
|
|
218
|
+
const rightGutter = callouts ? 22 + 4 + rightW : 10;
|
|
219
|
+
// The pie keeps at least a quarter of the short side; when the labels want
|
|
220
|
+
// more than that leaves, they are truncated to what fits instead.
|
|
221
|
+
const minR = Math.max(12, Math.min(width, innerH) * 0.25);
|
|
222
|
+
const room = (width - leftGutter - rightGutter) / 2;
|
|
223
|
+
const r = Math.max(minR, Math.min(innerH / 2 - (callouts ? 34 : 10), room));
|
|
224
|
+
// Centred in the room the gutters leave. When even the floor radius does
|
|
225
|
+
// not fit between them, the pie sits in the middle and both sides truncate
|
|
226
|
+
// to what is left, rather than one side keeping its text and the other
|
|
227
|
+
// running off the edge.
|
|
228
|
+
const cx = callouts && room >= r ? leftGutter + room : width / 2;
|
|
229
|
+
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0));
|
|
230
|
+
const ir = r * innerFrac;
|
|
231
|
+
let angle = -Math.PI / 2;
|
|
232
|
+
const slices = s.values.map((v, i) => {
|
|
233
|
+
const frac = Math.max(0, v) / total;
|
|
234
|
+
const a0 = angle;
|
|
235
|
+
const a1 = angle + frac * Math.PI * 2;
|
|
236
|
+
angle = a1;
|
|
237
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
238
|
+
const mid = (a0 + a1) / 2;
|
|
239
|
+
const labelR = (r + ir) / 2 || r * 0.6;
|
|
240
|
+
const ox0 = cx + r * Math.cos(a0);
|
|
241
|
+
const oy0 = cy + r * Math.sin(a0);
|
|
242
|
+
const ox1 = cx + r * Math.cos(a1);
|
|
243
|
+
const oy1 = cy + r * Math.sin(a1);
|
|
244
|
+
let path;
|
|
245
|
+
if (frac >= 0.999) {
|
|
246
|
+
path = ir
|
|
247
|
+
? `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z` +
|
|
248
|
+
`M${round(cx - ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx + ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx - ir)},${round(cy)} Z`
|
|
249
|
+
: `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z`;
|
|
250
|
+
}
|
|
251
|
+
else if (ir > 0) {
|
|
252
|
+
const ix0 = cx + ir * Math.cos(a0);
|
|
253
|
+
const iy0 = cy + ir * Math.sin(a0);
|
|
254
|
+
const ix1 = cx + ir * Math.cos(a1);
|
|
255
|
+
const iy1 = cy + ir * Math.sin(a1);
|
|
256
|
+
path =
|
|
257
|
+
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
258
|
+
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`;
|
|
262
|
+
}
|
|
263
|
+
const catLabel = spec.categories[i] ?? String(i);
|
|
264
|
+
return {
|
|
265
|
+
path,
|
|
266
|
+
// The same precedence as a bar: a category colour, then the series'
|
|
267
|
+
// per-point colour (a tree node's, say), then the palette.
|
|
268
|
+
color: spec.categoryColors?.[catLabel] ?? s.colors?.[i] ?? palette[i % palette.length],
|
|
269
|
+
label: catLabel,
|
|
270
|
+
value: v,
|
|
271
|
+
percent: frac * 100,
|
|
272
|
+
cx: round(cx + labelR * Math.cos(mid)),
|
|
273
|
+
cy: round(cy + labelR * Math.sin(mid)),
|
|
274
|
+
arc: { a0, a1, r, ir, cx, cy },
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
if (callouts)
|
|
278
|
+
layoutPieCallouts(slices, cx, cy, r, frame.top, frame.top + innerH, width);
|
|
279
|
+
return {
|
|
280
|
+
...empty,
|
|
281
|
+
slices,
|
|
282
|
+
legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? s.colors?.[i] ?? palette[i % palette.length] })),
|
|
283
|
+
donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Callout labels round a pie: a short leader from the slice's edge along its
|
|
288
|
+
* mid-angle, a horizontal run, then the text outside. Labels on each side are
|
|
289
|
+
* pushed apart from the top down so they never overlap, and a sliver under
|
|
290
|
+
* 1.5% gets no callout at all (its leader would land on a neighbour's).
|
|
291
|
+
*/
|
|
292
|
+
function layoutPieCallouts(slices, cx, cy, r, top, bottom, width) {
|
|
293
|
+
const GAP = 13;
|
|
294
|
+
// Characters of label that fit between the leader's run and the edge on
|
|
295
|
+
// each side, less the " NN%" the renderer appends; at least four so a name
|
|
296
|
+
// is recognisable.
|
|
297
|
+
const charsFor = (room) => Math.max(4, Math.floor((room - r - 26) / 6.3) - 4);
|
|
298
|
+
const maxCharsRight = charsFor(width - cx);
|
|
299
|
+
const maxCharsLeft = charsFor(cx);
|
|
300
|
+
const sides = [];
|
|
301
|
+
for (const slice of slices) {
|
|
302
|
+
if (!slice.arc || slice.percent < 1.5)
|
|
303
|
+
continue;
|
|
304
|
+
const mid = (slice.arc.a0 + slice.arc.a1) / 2;
|
|
305
|
+
sides.push({ slice, mid, ty: cy + Math.sin(mid) * (r + 16), right: Math.cos(mid) >= 0 });
|
|
306
|
+
}
|
|
307
|
+
for (const right of [true, false]) {
|
|
308
|
+
const list = sides.filter((c) => c.right === right).sort((a, b) => a.ty - b.ty);
|
|
309
|
+
// Push down from the top, then back up from the bottom, so a crowded side
|
|
310
|
+
// spreads out rather than piling below the plot.
|
|
311
|
+
for (let k = 1; k < list.length; k += 1)
|
|
312
|
+
list[k].ty = Math.max(list[k].ty, list[k - 1].ty + GAP);
|
|
313
|
+
const floor = bottom - 6;
|
|
314
|
+
for (let k = list.length - 1; k >= 0; k -= 1) {
|
|
315
|
+
const limit = k === list.length - 1 ? floor : list[k + 1].ty - GAP;
|
|
316
|
+
list[k].ty = Math.min(list[k].ty, limit);
|
|
317
|
+
}
|
|
318
|
+
for (const c of list)
|
|
319
|
+
if (c.ty < top + 6)
|
|
320
|
+
c.ty = top + 6;
|
|
321
|
+
for (const c of list) {
|
|
322
|
+
const x1 = cx + Math.cos(c.mid) * (r + 2);
|
|
323
|
+
const y1 = cy + Math.sin(c.mid) * (r + 2);
|
|
324
|
+
const x2 = cx + Math.cos(c.mid) * (r + 12);
|
|
325
|
+
const y2 = c.ty;
|
|
326
|
+
const x3 = right ? cx + r + 22 : cx - r - 22;
|
|
327
|
+
c.slice.callout = {
|
|
328
|
+
x1: round(x1), y1: round(y1), x2: round(x2), y2: round(y2), x3: round(x3),
|
|
329
|
+
tx: round(x3 + (right ? 4 : -4)), ty: round(c.ty), anchor: right ? 'start' : 'end',
|
|
330
|
+
maxChars: right ? maxCharsRight : maxCharsLeft,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// ---- Arcs ---------------------------------------------------------
|
|
336
|
+
// The one path builder every new polar type shares. Angles are radians,
|
|
337
|
+
// clockwise, with 12 o'clock at -PI/2 (the convention the pie already uses).
|
|
338
|
+
/**
|
|
339
|
+
* An annular sector from angle `a0` to `a1` between radii `r0` and `r1`, as
|
|
340
|
+
* an SVG path. `r0 = 0` gives a wedge; a span of a full turn gives a ring.
|
|
341
|
+
* Exposed so a custom mark can match the built-in polar types exactly.
|
|
342
|
+
*/
|
|
343
|
+
export function arcPath(cx, cy, r0, r1, a0, a1) {
|
|
344
|
+
const TAU = Math.PI * 2;
|
|
345
|
+
const span = Math.max(0, Math.min(TAU, a1 - a0));
|
|
346
|
+
if (span <= 0 || r1 <= 0)
|
|
347
|
+
return '';
|
|
348
|
+
const px = (r, a) => `${round(cx + r * Math.cos(a))},${round(cy + r * Math.sin(a))}`;
|
|
349
|
+
if (span >= TAU - 1e-6) {
|
|
350
|
+
// Two half-arcs, because a single arc cannot describe a full circle.
|
|
351
|
+
const outer = `M${px(r1, a0)} A${round(r1)},${round(r1)} 0 1 1 ${px(r1, a0 + Math.PI)} A${round(r1)},${round(r1)} 0 1 1 ${px(r1, a0)} Z`;
|
|
352
|
+
if (r0 <= 0)
|
|
353
|
+
return outer;
|
|
354
|
+
return `${outer} M${px(r0, a0)} A${round(r0)},${round(r0)} 0 1 0 ${px(r0, a0 + Math.PI)} A${round(r0)},${round(r0)} 0 1 0 ${px(r0, a0)} Z`;
|
|
355
|
+
}
|
|
356
|
+
const large = span > Math.PI ? 1 : 0;
|
|
357
|
+
if (r0 <= 0) {
|
|
358
|
+
return `M${round(cx)},${round(cy)} L${px(r1, a0)} A${round(r1)},${round(r1)} 0 ${large} 1 ${px(r1, a1)} Z`;
|
|
359
|
+
}
|
|
360
|
+
return (`M${px(r1, a0)} A${round(r1)},${round(r1)} 0 ${large} 1 ${px(r1, a1)} ` +
|
|
361
|
+
`L${px(r0, a1)} A${round(r0)},${round(r0)} 0 ${large} 0 ${px(r0, a0)} Z`);
|
|
362
|
+
}
|
|
363
|
+
/** The centre of the plot and the largest radius that fits, after the frame. */
|
|
364
|
+
function polarBox(ctx, pad = 24) {
|
|
365
|
+
const { width, height, frame } = ctx;
|
|
366
|
+
const innerH = Math.max(1, height - frame.top - frame.bottom);
|
|
367
|
+
const cx = width / 2;
|
|
368
|
+
const cy = frame.top + innerH / 2;
|
|
369
|
+
const r = Math.max(10, Math.min(width, innerH) / 2 - pad);
|
|
370
|
+
return { cx, cy, r, plot: { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r } };
|
|
371
|
+
}
|
|
372
|
+
/** A tint of `color` for a deeper ring: towards white on a light surface,
|
|
373
|
+
* towards the dark surface on a dark one. */
|
|
374
|
+
function tint(color, depth, theme) {
|
|
375
|
+
if (depth <= 0)
|
|
376
|
+
return color;
|
|
377
|
+
return sampleGradient([color, theme === 'dark' ? '#111827' : '#ffffff'], Math.min(0.6, depth * 0.22));
|
|
378
|
+
}
|
|
379
|
+
const START = -Math.PI / 2;
|
|
380
|
+
const TAU = Math.PI * 2;
|
|
381
|
+
// ---- Sunburst -----------------------------------------------------
|
|
382
|
+
// A radial treemap: the root's children share the first ring in proportion to
|
|
383
|
+
// their totals, each child's children share that child's angular span in the
|
|
384
|
+
// next ring out, and so on. Colour follows the top-level branch, tinted per
|
|
385
|
+
// depth, so a whole limb of the hierarchy reads as one hue.
|
|
386
|
+
/** @internal Lay out the sunburst family. Called by buildChart. */
|
|
387
|
+
export function layoutSunburst(ctx) {
|
|
388
|
+
const { spec, empty, palette, theme } = ctx;
|
|
389
|
+
const root = spec.tree ?? spec.treemap;
|
|
390
|
+
if (!root)
|
|
391
|
+
return { ...empty };
|
|
392
|
+
const { cx, cy, r, plot } = polarBox(ctx, 12);
|
|
393
|
+
const totalOf = (n) => n.children?.length ? n.children.reduce((s, c) => s + totalOf(c), 0) : Math.max(0, n.value ?? 0);
|
|
394
|
+
const depthOf = (n) => (n.children?.length ? 1 + Math.max(...n.children.map(depthOf)) : 0);
|
|
395
|
+
const seed = root.children ?? [root];
|
|
396
|
+
const total = seed.reduce((s, c) => s + totalOf(c), 0);
|
|
397
|
+
if (total <= 0)
|
|
398
|
+
return { ...empty, plot };
|
|
399
|
+
const depth = Math.max(1, depthOf({ name: '', children: seed }));
|
|
400
|
+
const innerFrac = Math.min(0.6, Math.max(0, spec.innerRadius ?? 0.18));
|
|
401
|
+
const r0 = r * innerFrac;
|
|
402
|
+
const ringW = (r - r0) / depth;
|
|
403
|
+
const arcs = [];
|
|
404
|
+
const walk = (nodes, a0, a1, d, color, path) => {
|
|
405
|
+
const sum = nodes.reduce((s, c) => s + totalOf(c), 0);
|
|
406
|
+
if (sum <= 0)
|
|
407
|
+
return;
|
|
408
|
+
let a = a0;
|
|
409
|
+
nodes.forEach((n, i) => {
|
|
410
|
+
const v = totalOf(n);
|
|
411
|
+
if (v <= 0)
|
|
412
|
+
return;
|
|
413
|
+
const span = ((a1 - a0) * v) / sum;
|
|
414
|
+
const c = n.color ?? (d === 0 ? palette[i % palette.length] : color);
|
|
415
|
+
const fill = d === 0 ? c : tint(c, d, theme);
|
|
416
|
+
const ri = r0 + ringW * d;
|
|
417
|
+
const ro = ri + ringW - 1;
|
|
418
|
+
const mid = a + span / 2;
|
|
419
|
+
const lr = (ri + ro) / 2;
|
|
420
|
+
const nodePath = [...path, n.name];
|
|
421
|
+
arcs.push({
|
|
422
|
+
path: arcPath(cx, cy, ri, ro, a, a + span),
|
|
423
|
+
color: fill,
|
|
424
|
+
textColor: pickContrastText(fill),
|
|
425
|
+
label: n.name,
|
|
426
|
+
series: path[path.length - 1] ?? '',
|
|
427
|
+
value: v,
|
|
428
|
+
a0: a,
|
|
429
|
+
a1: a + span,
|
|
430
|
+
r0: ri,
|
|
431
|
+
r1: ro,
|
|
432
|
+
cx,
|
|
433
|
+
cy,
|
|
434
|
+
depth: d,
|
|
435
|
+
nodePath,
|
|
436
|
+
lx: round(cx + lr * Math.cos(mid)),
|
|
437
|
+
ly: round(cy + lr * Math.sin(mid)),
|
|
438
|
+
});
|
|
439
|
+
if (n.children?.length)
|
|
440
|
+
walk(n.children, a, a + span, d + 1, c, nodePath);
|
|
441
|
+
a += span;
|
|
442
|
+
});
|
|
443
|
+
};
|
|
444
|
+
walk(seed, START, START + TAU, 0, null, []);
|
|
445
|
+
return {
|
|
446
|
+
...empty,
|
|
447
|
+
plot,
|
|
448
|
+
arcs,
|
|
449
|
+
legend: seed.map((n, i) => ({ label: n.name, color: n.color ?? palette[i % palette.length] })),
|
|
450
|
+
donut: r0 > 0 ? { cx: round(cx), cy: round(cy), r: round(r0), total } : null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
// ---- Radial bar ---------------------------------------------------
|
|
454
|
+
// One ring per category (per series), sweeping clockwise from 12 o'clock by
|
|
455
|
+
// value over the largest value. A faint full track behind each ring shows
|
|
456
|
+
// the scale. The first category is the innermost ring.
|
|
457
|
+
/** @internal Lay out the radial bar family. Called by buildChart. */
|
|
458
|
+
export function layoutRadialBar(ctx) {
|
|
459
|
+
const { spec, series, empty, palette } = ctx;
|
|
460
|
+
if (!series.length || !spec.categories.length)
|
|
461
|
+
return { ...empty };
|
|
462
|
+
const { cx, cy, r, plot } = polarBox(ctx, 12);
|
|
463
|
+
let vMax = 0;
|
|
464
|
+
for (const s of series)
|
|
465
|
+
for (const v of s.values)
|
|
466
|
+
if (Number.isFinite(v) && v > vMax)
|
|
467
|
+
vMax = v;
|
|
468
|
+
if (vMax <= 0)
|
|
469
|
+
vMax = 1;
|
|
470
|
+
const n = spec.categories.length;
|
|
471
|
+
const rings = n * series.length;
|
|
472
|
+
const innerFrac = Math.min(0.6, Math.max(0, spec.innerRadius ?? 0.2));
|
|
473
|
+
const r0 = r * innerFrac;
|
|
474
|
+
const ringW = (r - r0) / Math.max(1, rings);
|
|
475
|
+
const gap = Math.min(3, ringW * 0.2);
|
|
476
|
+
const arcs = [];
|
|
477
|
+
for (let i = 0; i < n; i += 1) {
|
|
478
|
+
series.forEach((s, si) => {
|
|
479
|
+
const v = s.values[i];
|
|
480
|
+
// A zero (a hidden category, or no data) draws nothing rather than an
|
|
481
|
+
// empty path, so the ring simply disappears the way a pie slice does.
|
|
482
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
483
|
+
return;
|
|
484
|
+
const k = i * series.length + si;
|
|
485
|
+
const ri = r0 + ringW * k + gap / 2;
|
|
486
|
+
const ro = ri + ringW - gap;
|
|
487
|
+
const span = Math.min(TAU * 0.9999, (Math.max(0, v) / vMax) * TAU);
|
|
488
|
+
const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]] ?? s.colors?.[i] ?? palette[i % palette.length]) : s.colors?.[i] ?? s.color;
|
|
489
|
+
const mid = START + span / 2;
|
|
490
|
+
const lr = (ri + ro) / 2;
|
|
491
|
+
arcs.push({
|
|
492
|
+
path: arcPath(cx, cy, ri, ro, START, START + span),
|
|
493
|
+
trackPath: arcPath(cx, cy, ri, ro, START, START + TAU),
|
|
494
|
+
color,
|
|
495
|
+
textColor: pickContrastText(color),
|
|
496
|
+
label: spec.categories[i],
|
|
497
|
+
series: s.label,
|
|
498
|
+
value: v,
|
|
499
|
+
a0: START,
|
|
500
|
+
a1: START + span,
|
|
501
|
+
r0: ri,
|
|
502
|
+
r1: ro,
|
|
503
|
+
cx,
|
|
504
|
+
cy,
|
|
505
|
+
lx: round(cx + lr * Math.cos(mid)),
|
|
506
|
+
ly: round(cy + lr * Math.sin(mid)),
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
...empty,
|
|
512
|
+
plot,
|
|
513
|
+
arcs,
|
|
514
|
+
legend: series.length === 1
|
|
515
|
+
? spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length] }))
|
|
516
|
+
: empty.legend,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
// ---- Radial column / nightingale ----------------------------------
|
|
520
|
+
// Bars on a polar axis: each category owns an angular slot, each series a
|
|
521
|
+
// sub-slot (grouped) or a radial layer (stacked). Radius follows the value
|
|
522
|
+
// through nice rings, which the renderer draws as the axis. A nightingale
|
|
523
|
+
// (Florence's rose) is the same with area-proportional radii (sqrt of the
|
|
524
|
+
// value) and no gap between the petals, so a value twice as big looks twice
|
|
525
|
+
// as big rather than four times.
|
|
526
|
+
/** @internal Lay out the radial column and nightingale families. Called by buildChart. */
|
|
527
|
+
export function layoutRadialColumn(ctx) {
|
|
528
|
+
const { spec, series, empty, palette, stacked } = ctx;
|
|
529
|
+
if (!series.length || !spec.categories.length)
|
|
530
|
+
return { ...empty };
|
|
531
|
+
const rose = spec.type === 'nightingale';
|
|
532
|
+
const { cx, cy, r, plot } = polarBox(ctx, 22);
|
|
533
|
+
const n = spec.categories.length;
|
|
534
|
+
const stack = stacked || (rose && series.length > 1);
|
|
535
|
+
// Domain over values, or stacked totals.
|
|
536
|
+
let vMax = 0;
|
|
537
|
+
if (stack) {
|
|
538
|
+
for (let i = 0; i < n; i += 1) {
|
|
539
|
+
let t = 0;
|
|
540
|
+
for (const s of series)
|
|
541
|
+
t += Number.isFinite(s.values[i]) ? Math.max(0, s.values[i]) : 0;
|
|
542
|
+
if (t > vMax)
|
|
543
|
+
vMax = t;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
for (const s of series)
|
|
548
|
+
for (const v of s.values)
|
|
549
|
+
if (Number.isFinite(v) && v > vMax)
|
|
550
|
+
vMax = v;
|
|
551
|
+
}
|
|
552
|
+
const dom = axisScale(0, vMax > 0 ? vMax : 1, spec.yAxis ?? {});
|
|
553
|
+
const innerFrac = Math.min(0.5, Math.max(0, spec.innerRadius ?? (rose ? 0 : 0.12)));
|
|
554
|
+
const r0 = r * innerFrac;
|
|
555
|
+
const radiusOf = (v) => {
|
|
556
|
+
const t = Math.max(0, Math.min(1, (v - dom.min) / (dom.max - dom.min || 1)));
|
|
557
|
+
return r0 + (r - r0) * (rose ? Math.sqrt(t) : t);
|
|
558
|
+
};
|
|
559
|
+
const slot = TAU / n;
|
|
560
|
+
const pad = rose ? 0 : slot * 0.15;
|
|
561
|
+
const arcs = [];
|
|
562
|
+
for (let i = 0; i < n; i += 1) {
|
|
563
|
+
const s0 = START + slot * i + pad / 2;
|
|
564
|
+
const s1 = START + slot * (i + 1) - pad / 2;
|
|
565
|
+
if (stack) {
|
|
566
|
+
let cum = 0;
|
|
567
|
+
series.forEach((s) => {
|
|
568
|
+
const v = s.values[i];
|
|
569
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
570
|
+
return;
|
|
571
|
+
const ri = radiusOf(cum);
|
|
572
|
+
cum += v;
|
|
573
|
+
const ro = radiusOf(cum);
|
|
574
|
+
const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]] ?? s.colors?.[i] ?? palette[i % palette.length]) : s.colors?.[i] ?? s.color;
|
|
575
|
+
const mid = (s0 + s1) / 2;
|
|
576
|
+
const lr = (ri + ro) / 2;
|
|
577
|
+
arcs.push({ path: arcPath(cx, cy, ri, ro, s0, s1), color, textColor: pickContrastText(color), label: spec.categories[i], series: s.label, value: v, a0: s0, a1: s1, r0: ri, r1: ro, cx, cy, lx: round(cx + lr * Math.cos(mid)), ly: round(cy + lr * Math.sin(mid)) });
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
const sub = (s1 - s0) / series.length;
|
|
582
|
+
series.forEach((s, si) => {
|
|
583
|
+
const v = s.values[i];
|
|
584
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
585
|
+
return;
|
|
586
|
+
const a0 = s0 + sub * si;
|
|
587
|
+
const a1 = a0 + sub * (series.length > 1 ? 0.92 : 1);
|
|
588
|
+
const ro = radiusOf(v);
|
|
589
|
+
const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]] ?? s.colors?.[i] ?? palette[i % palette.length]) : s.colors?.[i] ?? s.color;
|
|
590
|
+
const mid = (a0 + a1) / 2;
|
|
591
|
+
const lr = (r0 + ro) / 2;
|
|
592
|
+
arcs.push({ path: arcPath(cx, cy, r0, ro, a0, a1), color, textColor: pickContrastText(color), label: spec.categories[i], series: s.label, value: v, a0, a1, r0, r1: ro, cx, cy, lx: round(cx + lr * Math.cos(mid)), ly: round(cy + lr * Math.sin(mid)) });
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const polarAxes = !ctx.axes.x.labels ? [] : spec.categories.map((label, i) => {
|
|
597
|
+
const a = START + slot * i + slot / 2;
|
|
598
|
+
const lr = r + 10;
|
|
599
|
+
return { label, x: round(cx + lr * Math.cos(a)), y: round(cy + lr * Math.sin(a)) };
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
...empty,
|
|
603
|
+
plot,
|
|
604
|
+
arcs,
|
|
605
|
+
polarAxes,
|
|
606
|
+
polarRings: dom.ticks.filter((t) => t > dom.min).map((t) => radiusOf(t)),
|
|
607
|
+
radarCenter: { cx, cy, r },
|
|
608
|
+
legend: series.length === 1
|
|
609
|
+
? spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length] }))
|
|
610
|
+
: empty.legend,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
// ---- Chord --------------------------------------------------------
|
|
614
|
+
// Flows between groups round a circle. Each node gets an arc proportional to
|
|
615
|
+
// everything flowing in or out of it; each link a ribbon from its sub-span on
|
|
616
|
+
// the source arc to its sub-span on the target arc, coloured by the source.
|
|
617
|
+
/** @internal Lay out the chord family. Called by buildChart. */
|
|
618
|
+
export function layoutChord(ctx) {
|
|
619
|
+
const { spec, empty, palette } = ctx;
|
|
620
|
+
const nodes = spec.sankeyNodes ?? [];
|
|
621
|
+
const links = (spec.sankeyLinks ?? []).filter((l) => Number.isFinite(l.value) && l.value > 0);
|
|
622
|
+
if (!nodes.length || !links.length)
|
|
623
|
+
return { ...empty };
|
|
624
|
+
const { cx, cy, r, plot } = polarBox(ctx, 28);
|
|
625
|
+
const totals = new Map();
|
|
626
|
+
for (const l of links) {
|
|
627
|
+
totals.set(l.source, (totals.get(l.source) ?? 0) + l.value);
|
|
628
|
+
if (l.target !== l.source)
|
|
629
|
+
totals.set(l.target, (totals.get(l.target) ?? 0) + l.value);
|
|
630
|
+
}
|
|
631
|
+
const ordered = nodes.filter((n) => (totals.get(n.id) ?? 0) > 0);
|
|
632
|
+
const grand = ordered.reduce((s, n) => s + (totals.get(n.id) ?? 0), 0);
|
|
633
|
+
if (grand <= 0)
|
|
634
|
+
return { ...empty, plot };
|
|
635
|
+
const padAngle = Math.min(0.06, TAU / (ordered.length * 8));
|
|
636
|
+
const usable = TAU - padAngle * ordered.length;
|
|
637
|
+
const ringW = Math.max(8, r * 0.09);
|
|
638
|
+
const rInner = r - ringW;
|
|
639
|
+
const arcs = [];
|
|
640
|
+
const span = new Map();
|
|
641
|
+
let a = START;
|
|
642
|
+
ordered.forEach((n, i) => {
|
|
643
|
+
const t = totals.get(n.id) ?? 0;
|
|
644
|
+
const s = (usable * t) / grand;
|
|
645
|
+
const color = n.color ?? palette[i % palette.length];
|
|
646
|
+
span.set(n.id, { a0: a, a1: a + s, cursor: a, color });
|
|
647
|
+
const mid = a + s / 2;
|
|
648
|
+
const lr = r + 12;
|
|
649
|
+
arcs.push({
|
|
650
|
+
path: arcPath(cx, cy, rInner, r, a, a + s),
|
|
651
|
+
color,
|
|
652
|
+
textColor: pickContrastText(color),
|
|
653
|
+
label: n.label ?? n.id,
|
|
654
|
+
series: '',
|
|
655
|
+
value: t,
|
|
656
|
+
a0: a,
|
|
657
|
+
a1: a + s,
|
|
658
|
+
r0: rInner,
|
|
659
|
+
r1: r,
|
|
660
|
+
cx,
|
|
661
|
+
cy,
|
|
662
|
+
lx: round(cx + lr * Math.cos(mid)),
|
|
663
|
+
ly: round(cy + lr * Math.sin(mid)),
|
|
664
|
+
});
|
|
665
|
+
a += s + padAngle;
|
|
666
|
+
});
|
|
667
|
+
// Sub-spans: outgoing links first on the source, then incoming on the target,
|
|
668
|
+
// each consuming an angle proportional to its value.
|
|
669
|
+
const px = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
|
|
670
|
+
const chordRibbons = [];
|
|
671
|
+
const take = (id, value) => {
|
|
672
|
+
const g = span.get(id);
|
|
673
|
+
if (!g)
|
|
674
|
+
return null;
|
|
675
|
+
const w = ((g.a1 - g.a0) * value) / (totals.get(id) ?? 1);
|
|
676
|
+
const out = { a0: g.cursor, a1: g.cursor + w };
|
|
677
|
+
g.cursor += w;
|
|
678
|
+
return out;
|
|
679
|
+
};
|
|
680
|
+
const sorted = links.slice().sort((x, y) => y.value - x.value);
|
|
681
|
+
for (const l of sorted) {
|
|
682
|
+
const s = take(l.source, l.value);
|
|
683
|
+
const t = l.target === l.source ? s : take(l.target, l.value);
|
|
684
|
+
if (!s || !t)
|
|
685
|
+
continue;
|
|
686
|
+
const largeS = s.a1 - s.a0 > Math.PI ? 1 : 0;
|
|
687
|
+
const largeT = t.a1 - t.a0 > Math.PI ? 1 : 0;
|
|
688
|
+
const path = `M${px(rInner, s.a0)} A${round(rInner)},${round(rInner)} 0 ${largeS} 1 ${px(rInner, s.a1)} ` +
|
|
689
|
+
`Q${round(cx)},${round(cy)} ${px(rInner, t.a0)} A${round(rInner)},${round(rInner)} 0 ${largeT} 1 ${px(rInner, t.a1)} ` +
|
|
690
|
+
`Q${round(cx)},${round(cy)} ${px(rInner, s.a0)} Z`;
|
|
691
|
+
chordRibbons.push({ path, color: l.color ?? span.get(l.source).color, source: l.source, target: l.target, value: l.value });
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
...empty,
|
|
695
|
+
plot,
|
|
696
|
+
arcs,
|
|
697
|
+
chordRibbons,
|
|
698
|
+
legend: ordered.map((n, i) => ({ label: n.label ?? n.id, color: n.color ?? palette[i % palette.length] })),
|
|
699
|
+
};
|
|
700
|
+
}
|