@svgrid/grid 3.0.3 → 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-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-Bugf5XlI.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-CHOMnW-c.js +0 -602
- package/dist/cdn/GridMenus-DhdPF700.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
- package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
- package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
- package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
- package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
package/dist/chart.js
CHANGED
|
@@ -1,698 +1,109 @@
|
|
|
1
|
+
import { DEFAULT_PALETTE, niceScale } from './chart-scale.js';
|
|
2
|
+
import { binValues, boxStats, bucketStart, reducerNeedsSamples, reduceValues } from './chart-stats.js';
|
|
3
|
+
import { chartFrame, kindOf, resolveAxes } from './chart-axes.js';
|
|
4
|
+
import { pickCategories } from './chart-decimate.js';
|
|
5
|
+
import { layoutBullet, layoutCartesian, layoutHorizontalBars, layoutScatter } from './chart-cartesian.js';
|
|
6
|
+
import { layoutChord, layoutGauge, layoutPie, layoutRadar, layoutRadialBar, layoutRadialColumn, layoutSunburst } from './chart-polar.js';
|
|
7
|
+
import { layoutFunnel, layoutSankey, layoutWaterfall } from './chart-flow.js';
|
|
8
|
+
import { layoutCalendar, layoutHeatmap } from './chart-grid.js';
|
|
9
|
+
import { layoutTreemap } from './chart-hierarchy.js';
|
|
10
|
+
export { DEFAULT_PALETTE, formatChartValue, niceLogScale, niceScale, ordinalDateTicks, pickContrastText, sampleGradient, } from './chart-scale.js';
|
|
11
|
+
export { chartScales } from './chart-axes.js';
|
|
12
|
+
export { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate.js';
|
|
13
|
+
export { buildLinePath, layoutDataLabels, markerPath, streamBaseline } from './chart-cartesian.js';
|
|
14
|
+
export { arcPath } from './chart-polar.js';
|
|
15
|
+
export { pivotResultToChartSpec, pivotChartType, pivotFilterColumn, bucketsToChartSpec } from './chart-pivot.js';
|
|
16
|
+
export { heikinAshi, resampleOhlc, rowsToOhlcSpec, guessOhlcColumns, splitPanelIndicators, ohlcDirectOptions } from './chart-financial.js';
|
|
17
|
+
import { rowsToOhlcSpec } from './chart-financial.js';
|
|
18
|
+
export { binValues, boxStats, bucketStart, percentile, reduceValues, exponentialMovingAverage, linearTrend, linearFit, polynomialFit, exponentialFit, logarithmicFit, powerFit, rSquared, pearson, simpleMovingAverage, } from './chart-stats.js';
|
|
19
|
+
export { computeOverlay, computeOverlayFit, regressionFit, overlayName, bollingerBands, rsi, macd, vwap, atr, stochastic, wma, obv, indicatorPane, } from './chart-indicators.js';
|
|
1
20
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Supports: grouped + stacked bars, line, area, pie/donut, combo charts
|
|
8
|
-
* (per-series type), a secondary (right) Y axis, signed Y domains (negative
|
|
9
|
-
* values drop below a zero baseline), and nice auto-scaled ticks.
|
|
10
|
-
*/
|
|
11
|
-
// The one import this module has. `Intl.NumberFormat` is expensive enough to
|
|
12
|
-
// construct that a chart with 8 axis ticks and 200 data labels would rebuild it
|
|
13
|
-
// 200 times a frame, and the grid already solved that for cells - so reuse that
|
|
14
|
-
// cache rather than starting a second one. Still DOM-free: it is plain TS.
|
|
15
|
-
import { getNumberFormatter } from './cell-formatting.js';
|
|
16
|
-
/**
|
|
17
|
-
* Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
|
|
18
|
-
*
|
|
19
|
-
* Whiskers stop at the last observation INSIDE the fence rather than at the
|
|
20
|
-
* fence itself, which is what makes them read as real data; anything past them
|
|
21
|
-
* comes back in `outliers`. Quartiles use linear interpolation between the two
|
|
22
|
-
* neighbouring order statistics.
|
|
23
|
-
*
|
|
24
|
-
* Returns `null` for an empty sample, so a category with no observations is a
|
|
25
|
-
* gap rather than a box drawn at zero.
|
|
26
|
-
*/
|
|
27
|
-
export function boxStats(sample, whisker = 1.5) {
|
|
28
|
-
const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b);
|
|
29
|
-
if (!v.length)
|
|
30
|
-
return null;
|
|
31
|
-
const q = (p) => {
|
|
32
|
-
const pos = (v.length - 1) * p;
|
|
33
|
-
const lo = Math.floor(pos);
|
|
34
|
-
const hi = Math.ceil(pos);
|
|
35
|
-
return lo === hi ? v[lo] : v[lo] + (v[hi] - v[lo]) * (pos - lo);
|
|
36
|
-
};
|
|
37
|
-
const q1 = q(0.25);
|
|
38
|
-
const median = q(0.5);
|
|
39
|
-
const q3 = q(0.75);
|
|
40
|
-
const fenceLo = q1 - whisker * (q3 - q1);
|
|
41
|
-
const fenceHi = q3 + whisker * (q3 - q1);
|
|
42
|
-
const inside = v.filter((n) => n >= fenceLo && n <= fenceHi);
|
|
43
|
-
const outliers = v.filter((n) => n < fenceLo || n > fenceHi);
|
|
44
|
-
return {
|
|
45
|
-
// `inside` can only be empty if every point is an outlier, which the fence
|
|
46
|
-
// rule makes impossible (q1 and q3 are always within it) - but a degenerate
|
|
47
|
-
// sample should still produce a box rather than `undefined` coordinates.
|
|
48
|
-
min: inside.length ? inside[0] : v[0],
|
|
49
|
-
q1,
|
|
50
|
-
median,
|
|
51
|
-
q3,
|
|
52
|
-
max: inside.length ? inside[inside.length - 1] : v[v.length - 1],
|
|
53
|
-
...(outliers.length ? { outliers } : {}),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Category-axis labels, thinned so they do not overlap.
|
|
58
|
-
*
|
|
59
|
-
* A category axis used to emit one tick per category however many there were.
|
|
60
|
-
* At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
|
|
61
|
-
* grey band - the labels were the single biggest thing the renderer had to put
|
|
62
|
-
* in the DOM, and none of them could be read. The time and ordinal-time axes
|
|
63
|
-
* already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
|
|
64
|
-
* the plain category axis in line.
|
|
65
|
-
*
|
|
66
|
-
* The first category always gets a label, so a thinned axis still starts where
|
|
67
|
-
* the data does.
|
|
68
|
-
*/
|
|
69
|
-
function thinCategoryTicks(categories, xCenter, slot, rotated) {
|
|
70
|
-
// Rotated labels run diagonally and pack far tighter than upright ones. 18px
|
|
71
|
-
// is deliberately just under the spacing a 40-category chart at 800px already
|
|
72
|
-
// had, so charts that read fine before are untouched and only genuinely
|
|
73
|
-
// overlapping axes get thinned.
|
|
74
|
-
const minPx = rotated ? 18 : 60;
|
|
75
|
-
const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)));
|
|
76
|
-
const out = [];
|
|
77
|
-
for (let i = 0; i < categories.length; i += step)
|
|
78
|
-
out.push({ label: categories[i], x: xCenter(i) });
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
/** Normalize one `errors` entry to an absolute low/high pair around `value`. */
|
|
82
|
-
function errorSpan(e, value) {
|
|
83
|
-
if (e == null)
|
|
84
|
-
return null;
|
|
85
|
-
if (typeof e === 'number') {
|
|
86
|
-
if (!Number.isFinite(e))
|
|
87
|
-
return null;
|
|
88
|
-
const m = Math.abs(e);
|
|
89
|
-
return { lo: value - m, hi: value + m };
|
|
90
|
-
}
|
|
91
|
-
if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi))
|
|
92
|
-
return null;
|
|
93
|
-
return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) };
|
|
94
|
-
}
|
|
95
|
-
/** Series colours used when a {@link ChartSeries} sets none, in order. */
|
|
96
|
-
export const DEFAULT_PALETTE = [
|
|
97
|
-
'#2563eb',
|
|
98
|
-
'#16a34a',
|
|
99
|
-
'#f59e0b',
|
|
100
|
-
'#ef4444',
|
|
101
|
-
'#8b5cf6',
|
|
102
|
-
'#0ea5e9',
|
|
103
|
-
'#ec4899',
|
|
104
|
-
'#14b8a6',
|
|
105
|
-
];
|
|
106
|
-
function round(n) {
|
|
107
|
-
return Math.round(n * 100) / 100;
|
|
108
|
-
}
|
|
109
|
-
function niceNum(range, roundIt) {
|
|
110
|
-
if (range <= 0)
|
|
111
|
-
return 1;
|
|
112
|
-
const exp = Math.floor(Math.log10(range));
|
|
113
|
-
const f = range / Math.pow(10, exp);
|
|
114
|
-
let nf;
|
|
115
|
-
if (roundIt)
|
|
116
|
-
nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10;
|
|
117
|
-
else
|
|
118
|
-
nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
|
|
119
|
-
return nf * Math.pow(10, exp);
|
|
120
|
-
}
|
|
121
|
-
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
122
|
-
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
123
|
-
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a'];
|
|
124
|
-
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
125
|
-
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8'];
|
|
126
|
-
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
127
|
-
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
128
|
-
* as "cold" rather than as glaring white rectangles. */
|
|
129
|
-
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd'];
|
|
130
|
-
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa'];
|
|
131
|
-
function resolveColorScale(scale, vMin, vMax, theme = 'light') {
|
|
132
|
-
if (Array.isArray(scale) && scale.length >= 2)
|
|
133
|
-
return scale;
|
|
134
|
-
const dark = theme === 'dark';
|
|
135
|
-
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
136
|
-
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS;
|
|
137
|
-
}
|
|
138
|
-
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS;
|
|
139
|
-
}
|
|
140
|
-
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
141
|
-
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
142
|
-
export function sampleGradient(stops, t) {
|
|
143
|
-
if (!stops.length)
|
|
144
|
-
return '#888';
|
|
145
|
-
const clamped = Math.max(0, Math.min(1, t));
|
|
146
|
-
if (stops.length === 1)
|
|
147
|
-
return stops[0];
|
|
148
|
-
const pos = clamped * (stops.length - 1);
|
|
149
|
-
const i = Math.floor(pos);
|
|
150
|
-
const frac = pos - i;
|
|
151
|
-
const a = hexToRgb(stops[i]);
|
|
152
|
-
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]);
|
|
153
|
-
if (!a || !b)
|
|
154
|
-
return stops[i] ?? '#888';
|
|
155
|
-
const lerp = (x, y) => Math.round(x + (y - x) * frac);
|
|
156
|
-
const toHex = (n) => n.toString(16).padStart(2, '0');
|
|
157
|
-
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b));
|
|
158
|
-
}
|
|
159
|
-
function hexToRgb(hex) {
|
|
160
|
-
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
161
|
-
if (!m)
|
|
162
|
-
return null;
|
|
163
|
-
const n = parseInt(m[1], 16);
|
|
164
|
-
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
165
|
-
}
|
|
166
|
-
/** Pick a black or white text color that has the better contrast against
|
|
167
|
-
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
168
|
-
export function pickContrastText(bgHex) {
|
|
169
|
-
const rgb = hexToRgb(bgHex);
|
|
170
|
-
if (!rgb)
|
|
171
|
-
return '#0f172a';
|
|
172
|
-
const lin = (c) => {
|
|
173
|
-
const s = c / 255;
|
|
174
|
-
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
175
|
-
};
|
|
176
|
-
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
|
|
177
|
-
return L > 0.5 ? '#0f172a' : '#ffffff';
|
|
178
|
-
}
|
|
179
|
-
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
180
|
-
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
181
|
-
* by log-scale axes (yScale: 'log'). */
|
|
182
|
-
export function niceLogScale(min, max) {
|
|
183
|
-
// Only positive values are plottable on a log scale; callers should
|
|
184
|
-
// strip non-positive values before passing them in.
|
|
185
|
-
if (!Number.isFinite(min) || min <= 0)
|
|
186
|
-
min = 1;
|
|
187
|
-
if (!Number.isFinite(max) || max <= min)
|
|
188
|
-
max = min * 10;
|
|
189
|
-
const lo = Math.floor(Math.log10(min));
|
|
190
|
-
const hi = Math.ceil(Math.log10(max));
|
|
191
|
-
const ticks = [];
|
|
192
|
-
for (let p = lo; p <= hi; p += 1)
|
|
193
|
-
ticks.push(Math.pow(10, p));
|
|
194
|
-
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks };
|
|
195
|
-
}
|
|
196
|
-
/** Map a value to a fractional position [0..1] across the axis domain.
|
|
197
|
-
* Pass the appropriate fn into projection code so linear / log share the
|
|
198
|
-
* same plumbing. Returns null for non-positive values on log. */
|
|
199
|
-
function project(value, min, max, isLog) {
|
|
200
|
-
if (!Number.isFinite(value))
|
|
201
|
-
return null;
|
|
202
|
-
if (isLog) {
|
|
203
|
-
if (value <= 0 || min <= 0)
|
|
204
|
-
return null;
|
|
205
|
-
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min));
|
|
206
|
-
}
|
|
207
|
-
return (value - min) / (max - min);
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Turn a laid-out chart's axes into functions, so a caller can draw its own
|
|
211
|
-
* marks in the same coordinates the built-in ones use.
|
|
212
|
-
*
|
|
213
|
-
* This is the custom-series seam. Rather than a registry of mark types, the
|
|
214
|
-
* chart hands over its geometry and its scales and lets the caller render
|
|
215
|
-
* whatever SVG it likes into the plot - which is the Svelte-shaped answer, and
|
|
216
|
-
* means a custom mark is ordinary markup rather than a plugin.
|
|
217
|
-
*
|
|
218
|
-
* Deriving the scale from `geo.axes` matters: the domain a chart drew against
|
|
219
|
-
* is the NICE-ROUNDED one, stretched to include zero for bar charts and any
|
|
220
|
-
* reference lines. Recomputing it from the data outside would land custom marks
|
|
221
|
-
* a few pixels off the built-in ones, in a way that looks like a rendering bug.
|
|
222
|
-
*
|
|
223
|
-
* Returns `null` for a chart with no cartesian axes (pie, gauge, treemap,
|
|
224
|
-
* sankey, calendar, radar, funnel), where plot coordinates mean nothing.
|
|
225
|
-
*/
|
|
226
|
-
export function chartScales(geo) {
|
|
227
|
-
const a = geo.axes;
|
|
228
|
-
if (!a)
|
|
229
|
-
return null;
|
|
230
|
-
const { x: px, y: py, h: ph } = geo.plot;
|
|
231
|
-
const yFor = (dom) => (value) => {
|
|
232
|
-
const t = project(value, dom.min, dom.max, dom.log);
|
|
233
|
-
return t === null ? Number.NaN : py + ph - t * ph;
|
|
234
|
-
};
|
|
235
|
-
const left = yFor(a.y);
|
|
236
|
-
const right = a.y2 ? yFor(a.y2) : left;
|
|
237
|
-
const invFor = (dom) => (y) => {
|
|
238
|
-
const t = ph === 0 ? 0 : (py + ph - y) / ph;
|
|
239
|
-
if (!dom.log)
|
|
240
|
-
return dom.min + t * (dom.max - dom.min);
|
|
241
|
-
const lo = Math.log10(dom.min);
|
|
242
|
-
return 10 ** (lo + t * (Math.log10(dom.max) - lo));
|
|
243
|
-
};
|
|
244
|
-
return {
|
|
245
|
-
xOf: (i) => px + a.slot * i + a.slot / 2,
|
|
246
|
-
yOf: (value, axis) => (axis === 'right' ? right(value) : left(value)),
|
|
247
|
-
xInvert: (x) => Math.max(0, Math.min(a.count - 1, Math.floor((x - px) / (a.slot || 1)))),
|
|
248
|
-
yInvert: (y, axis) => (axis === 'right' && a.y2 ? invFor(a.y2)(y) : invFor(a.y)(y)),
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
// ---- Overlay math: trendline + moving averages -----------------------
|
|
252
|
-
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
253
|
-
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
254
|
-
* Breaks the path at `defined === false` gaps. */
|
|
255
|
-
export function buildLinePath(pts, smooth) {
|
|
256
|
-
if (!smooth) {
|
|
257
|
-
let path = '';
|
|
258
|
-
let pen = false;
|
|
259
|
-
for (const p of pts) {
|
|
260
|
-
if (!p.defined) {
|
|
261
|
-
pen = false;
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `;
|
|
265
|
-
pen = true;
|
|
266
|
-
}
|
|
267
|
-
return path.trim();
|
|
268
|
-
}
|
|
269
|
-
// Group defined-only runs; each run is smoothed independently.
|
|
270
|
-
const runs = [];
|
|
271
|
-
let cur = [];
|
|
272
|
-
for (const p of pts) {
|
|
273
|
-
if (p.defined)
|
|
274
|
-
cur.push({ x: p.x, y: p.y });
|
|
275
|
-
else if (cur.length) {
|
|
276
|
-
runs.push(cur);
|
|
277
|
-
cur = [];
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (cur.length)
|
|
281
|
-
runs.push(cur);
|
|
282
|
-
return runs.map(monotoneCubicPath).filter(Boolean).join(' ');
|
|
283
|
-
}
|
|
284
|
-
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
285
|
-
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
286
|
-
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
287
|
-
* to the neighbours along that tangent. */
|
|
288
|
-
function monotoneCubicPath(pts) {
|
|
289
|
-
const n = pts.length;
|
|
290
|
-
if (n === 0)
|
|
291
|
-
return '';
|
|
292
|
-
if (n === 1)
|
|
293
|
-
return `M${pts[0].x},${pts[0].y}`;
|
|
294
|
-
if (n === 2)
|
|
295
|
-
return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}`;
|
|
296
|
-
// Secant slopes between adjacent points.
|
|
297
|
-
const dx = new Array(n - 1);
|
|
298
|
-
const m = new Array(n - 1);
|
|
299
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
300
|
-
const d = pts[i + 1].x - pts[i].x;
|
|
301
|
-
dx[i] = d;
|
|
302
|
-
m[i] = d === 0 ? 0 : (pts[i + 1].y - pts[i].y) / d;
|
|
303
|
-
}
|
|
304
|
-
// Tangent at each point: average of neighbouring slopes, with sign
|
|
305
|
-
// checks that flatten the tangent when slopes change sign.
|
|
306
|
-
const tan = new Array(n);
|
|
307
|
-
tan[0] = m[0];
|
|
308
|
-
tan[n - 1] = m[n - 2];
|
|
309
|
-
for (let i = 1; i < n - 1; i += 1) {
|
|
310
|
-
if (m[i - 1] * m[i] <= 0)
|
|
311
|
-
tan[i] = 0;
|
|
312
|
-
else
|
|
313
|
-
tan[i] = (m[i - 1] + m[i]) / 2;
|
|
314
|
-
}
|
|
315
|
-
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
316
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
317
|
-
if (m[i] === 0) {
|
|
318
|
-
tan[i] = 0;
|
|
319
|
-
tan[i + 1] = 0;
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
const a = tan[i] / m[i];
|
|
323
|
-
const b = tan[i + 1] / m[i];
|
|
324
|
-
const h = Math.hypot(a, b);
|
|
325
|
-
if (h > 3) {
|
|
326
|
-
tan[i] = (3 / h) * a * m[i];
|
|
327
|
-
tan[i + 1] = (3 / h) * b * m[i];
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
331
|
-
let path = `M${pts[0].x},${pts[0].y}`;
|
|
332
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
333
|
-
const h = dx[i];
|
|
334
|
-
const c1x = pts[i].x + h / 3;
|
|
335
|
-
const c1y = pts[i].y + (tan[i] * h) / 3;
|
|
336
|
-
const c2x = pts[i + 1].x - h / 3;
|
|
337
|
-
const c2y = pts[i + 1].y - (tan[i + 1] * h) / 3;
|
|
338
|
-
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}`;
|
|
339
|
-
}
|
|
340
|
-
return path;
|
|
341
|
-
}
|
|
342
|
-
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
343
|
-
* Returns the fitted value at each x index, or NaN where the source value
|
|
344
|
-
* was non-finite. */
|
|
345
|
-
export function linearTrend(values) {
|
|
346
|
-
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0;
|
|
347
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
348
|
-
const y = values[i];
|
|
349
|
-
if (!Number.isFinite(y))
|
|
350
|
-
continue;
|
|
351
|
-
n += 1;
|
|
352
|
-
sumX += i;
|
|
353
|
-
sumY += y;
|
|
354
|
-
sumXX += i * i;
|
|
355
|
-
sumXY += i * y;
|
|
356
|
-
}
|
|
357
|
-
if (n < 2)
|
|
358
|
-
return values.map(() => NaN);
|
|
359
|
-
const denom = n * sumXX - sumX * sumX;
|
|
360
|
-
if (denom === 0)
|
|
361
|
-
return values.map(() => sumY / n);
|
|
362
|
-
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
363
|
-
const intercept = (sumY - slope * sumX) / n;
|
|
364
|
-
return values.map((_, i) => slope * i + intercept);
|
|
365
|
-
}
|
|
366
|
-
/** Simple moving average over a window of `period` values. Window centres
|
|
367
|
-
* trail to the right (typical for time-series). NaN for points before the
|
|
368
|
-
* window is full. */
|
|
369
|
-
export function simpleMovingAverage(values, period) {
|
|
370
|
-
if (period < 1)
|
|
371
|
-
return values.slice();
|
|
372
|
-
const out = new Array(values.length).fill(NaN);
|
|
373
|
-
let sum = 0, count = 0;
|
|
374
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
375
|
-
const v = values[i];
|
|
376
|
-
if (Number.isFinite(v)) {
|
|
377
|
-
sum += v;
|
|
378
|
-
count += 1;
|
|
379
|
-
}
|
|
380
|
-
if (i >= period) {
|
|
381
|
-
const drop = values[i - period];
|
|
382
|
-
if (Number.isFinite(drop)) {
|
|
383
|
-
sum -= drop;
|
|
384
|
-
count -= 1;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
if (i >= period - 1 && count > 0)
|
|
388
|
-
out[i] = sum / count;
|
|
389
|
-
}
|
|
390
|
-
return out;
|
|
391
|
-
}
|
|
392
|
-
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
393
|
-
export function exponentialMovingAverage(values, period) {
|
|
394
|
-
const alpha = 2 / (Math.max(1, period) + 1);
|
|
395
|
-
const out = new Array(values.length).fill(NaN);
|
|
396
|
-
let prev = null;
|
|
397
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
398
|
-
const v = values[i];
|
|
399
|
-
if (!Number.isFinite(v)) {
|
|
400
|
-
out[i] = prev ?? NaN;
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
prev = prev == null ? v : alpha * v + (1 - alpha) * prev;
|
|
404
|
-
out[i] = prev;
|
|
405
|
-
}
|
|
406
|
-
return out;
|
|
407
|
-
}
|
|
408
|
-
/** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
|
|
409
|
-
export function computeOverlay(values, spec) {
|
|
410
|
-
if (spec === 'linear')
|
|
411
|
-
return linearTrend(values);
|
|
412
|
-
const m = /^(sma|ema):(\d+)$/.exec(spec);
|
|
413
|
-
if (!m)
|
|
414
|
-
return values.map(() => NaN);
|
|
415
|
-
const period = Number(m[2]);
|
|
416
|
-
return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period);
|
|
417
|
-
}
|
|
418
|
-
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
419
|
-
export function niceScale(min, max, tickCount = 4) {
|
|
420
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
421
|
-
min = 0;
|
|
422
|
-
max = 1;
|
|
423
|
-
}
|
|
424
|
-
if (min === max) {
|
|
425
|
-
if (min === 0)
|
|
426
|
-
max = 1;
|
|
427
|
-
else {
|
|
428
|
-
min = Math.min(0, min);
|
|
429
|
-
max = Math.max(0, max);
|
|
430
|
-
}
|
|
431
|
-
if (min === max)
|
|
432
|
-
max = min + 1;
|
|
433
|
-
}
|
|
434
|
-
const range = niceNum(max - min, false);
|
|
435
|
-
const step = niceNum(range / Math.max(1, tickCount), true);
|
|
436
|
-
const nMin = Math.floor(min / step) * step;
|
|
437
|
-
const nMax = Math.ceil(max / step) * step;
|
|
438
|
-
const ticks = [];
|
|
439
|
-
for (let v = nMin; v <= nMax + step * 0.5; v += step)
|
|
440
|
-
ticks.push(round(v));
|
|
441
|
-
return { min: nMin, max: nMax, step, ticks };
|
|
442
|
-
}
|
|
443
|
-
function fmtTick(n) {
|
|
444
|
-
const abs = Math.abs(n);
|
|
445
|
-
if (abs >= 1000000)
|
|
446
|
-
return `${(n / 1000000).toFixed(abs % 1000000 ? 1 : 0)}M`;
|
|
447
|
-
if (abs >= 1000)
|
|
448
|
-
return `${(n / 1000).toFixed(abs % 1000 ? 1 : 0)}k`;
|
|
449
|
-
return String(Math.round(n * 100) / 100);
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
453
|
-
*
|
|
454
|
-
* Two modes, on purpose. With no `locale` and no `currency` this is the original
|
|
455
|
-
* locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
|
|
456
|
-
* `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
|
|
457
|
-
* and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
|
|
458
|
-
* compact suffixes all follow the locale.
|
|
459
|
-
*
|
|
460
|
-
* Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
|
|
461
|
-
* compact notation is not the same string even for `en-US` (`1.2K`, capital),
|
|
462
|
-
* so formatting everything through it would silently restyle every axis in every
|
|
463
|
-
* existing chart. Opting in is the only version of this that is not a surprise.
|
|
21
|
+
* The CSS custom properties `spec.style` sets on the chart's host, as one
|
|
22
|
+
* inline style string (empty when nothing is set). `background` becomes the
|
|
23
|
+
* chart's `--sg-bg` (exports read it as the page colour), `textColor` the
|
|
24
|
+
* text tokens, `gridColor` the border token, `fontSize` a scale every label
|
|
25
|
+
* multiplies (12px is 1), and `fontFamily` the host's font.
|
|
464
26
|
*/
|
|
465
|
-
export function
|
|
466
|
-
if (!
|
|
27
|
+
export function chartStyleVars(style) {
|
|
28
|
+
if (!style)
|
|
467
29
|
return '';
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
else if (style === 'percent') {
|
|
481
|
-
o.style = 'percent';
|
|
482
|
-
}
|
|
483
|
-
return getNumberFormatter(opts.locale, o).format(n);
|
|
484
|
-
}
|
|
485
|
-
if (format === 'currency')
|
|
486
|
-
return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`;
|
|
487
|
-
if (format === 'percent') {
|
|
488
|
-
const p = n * 100;
|
|
489
|
-
return `${Math.round(p * 10) / 10}%`;
|
|
490
|
-
}
|
|
491
|
-
return fmtTick(n);
|
|
492
|
-
}
|
|
493
|
-
const DAY = 86400000;
|
|
494
|
-
/** Nice date-tick timestamps across [min, max]. */
|
|
495
|
-
function dateTicks(tMin, tMax) {
|
|
496
|
-
const span = tMax - tMin;
|
|
497
|
-
const step = span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY;
|
|
498
|
-
const ticks = [];
|
|
499
|
-
for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step)
|
|
500
|
-
ticks.push(t);
|
|
501
|
-
if (!ticks.length)
|
|
502
|
-
ticks.push(tMin, tMax);
|
|
503
|
-
return ticks;
|
|
30
|
+
const out = [];
|
|
31
|
+
if (style.background)
|
|
32
|
+
out.push(`--sg-chart-bg:${style.background}`, `--sg-bg:${style.background}`);
|
|
33
|
+
if (style.textColor)
|
|
34
|
+
out.push(`--sg-fg:${style.textColor}`, `--sg-muted:${style.textColor}`);
|
|
35
|
+
if (style.gridColor)
|
|
36
|
+
out.push(`--sg-border:${style.gridColor}`);
|
|
37
|
+
if (style.fontSize && Number.isFinite(style.fontSize) && style.fontSize > 0)
|
|
38
|
+
out.push(`--sg-chart-font-scale:${Math.round((style.fontSize / 12) * 1000) / 1000}`);
|
|
39
|
+
if (style.fontFamily)
|
|
40
|
+
out.push(`font-family:${style.fontFamily}`);
|
|
41
|
+
return out.join(';');
|
|
504
42
|
}
|
|
505
43
|
/**
|
|
506
|
-
*
|
|
507
|
-
* `
|
|
508
|
-
*
|
|
509
|
-
* A time axis can put a tick anywhere, because x is a function of the
|
|
510
|
-
* timestamp. An ordinal axis cannot: x is a function of the index, so a tick
|
|
511
|
-
* has to land on a point that exists. This picks the first point of each
|
|
512
|
-
* calendar unit - day, week, month, year, whichever gets closest to `target`
|
|
513
|
-
* ticks without going over - so labels sit on real sessions and a weekend or a
|
|
514
|
-
* holiday never stretches the spacing.
|
|
44
|
+
* The responsive rules that apply at a rendered size, in spec order. Pure:
|
|
45
|
+
* `SvChart` uses it for the legend field, {@link resolveResponsive} for the
|
|
46
|
+
* spec patches.
|
|
515
47
|
*/
|
|
516
|
-
export function
|
|
517
|
-
if (
|
|
518
|
-
return
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
|
|
523
|
-
year: (d) => d.getUTCFullYear(),
|
|
524
|
-
};
|
|
525
|
-
let chosen = null;
|
|
526
|
-
for (const unit of ['day', 'week', 'month', 'year']) {
|
|
527
|
-
const at = [];
|
|
528
|
-
let prev = null;
|
|
529
|
-
for (let i = 0; i < times.length; i += 1) {
|
|
530
|
-
const t = times[i];
|
|
531
|
-
if (!Number.isFinite(t))
|
|
532
|
-
continue;
|
|
533
|
-
const k = keyOf[unit](new Date(t));
|
|
534
|
-
if (k !== prev)
|
|
535
|
-
at.push(i);
|
|
536
|
-
prev = k;
|
|
537
|
-
}
|
|
538
|
-
chosen = at;
|
|
539
|
-
if (at.length <= target * 2)
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
let out = chosen ?? [];
|
|
543
|
-
// Even the coarsest unit can be too dense (a decade of yearly points), and a
|
|
544
|
-
// single trading day yields one boundary. Thin, or fall back to plain strides.
|
|
545
|
-
if (out.length > target) {
|
|
546
|
-
const stride = Math.ceil(out.length / target);
|
|
547
|
-
out = out.filter((_, i) => i % stride === 0);
|
|
548
|
-
}
|
|
549
|
-
if (out.length < 2) {
|
|
550
|
-
const stride = Math.max(1, Math.ceil(times.length / target));
|
|
551
|
-
out = times.map((_, i) => i).filter((i) => i % stride === 0);
|
|
552
|
-
}
|
|
553
|
-
return out;
|
|
554
|
-
}
|
|
555
|
-
function fmtDate(t, span) {
|
|
556
|
-
const d = new Date(t);
|
|
557
|
-
if (span <= 70 * DAY)
|
|
558
|
-
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
559
|
-
if (span <= 800 * DAY)
|
|
560
|
-
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
|
|
561
|
-
return String(d.getFullYear());
|
|
48
|
+
export function matchResponsiveRules(rules, width, height) {
|
|
49
|
+
if (!rules?.length)
|
|
50
|
+
return [];
|
|
51
|
+
return rules.filter((r) => (r.maxWidth == null || width <= r.maxWidth) &&
|
|
52
|
+
(r.minWidth == null || width >= r.minWidth) &&
|
|
53
|
+
(r.maxHeight == null || height <= r.maxHeight));
|
|
562
54
|
}
|
|
563
55
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* to `'bar'`, which is what pie and scatter have always relied on.
|
|
56
|
+
* Apply `spec.responsive` for a rendered size: every matching rule's `spec`
|
|
57
|
+
* is merged over the input in order (later wins), the three axis objects one
|
|
58
|
+
* level deep. Returns the input itself when nothing matches, so callers can
|
|
59
|
+
* compare by identity. `width`, `height` and `responsive` in a rule are
|
|
60
|
+
* ignored; the size is the caller's.
|
|
570
61
|
*/
|
|
571
|
-
function
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
574
|
-
return
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return t;
|
|
579
|
-
return 'bar';
|
|
580
|
-
}
|
|
581
|
-
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
582
|
-
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
583
|
-
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
584
|
-
function axisDomain(list, categories, stacked, extra = [], isLog = false) {
|
|
585
|
-
let dMin = Infinity;
|
|
586
|
-
let dMax = -Infinity;
|
|
587
|
-
const note = (v) => {
|
|
588
|
-
if (!Number.isFinite(v))
|
|
589
|
-
return;
|
|
590
|
-
if (isLog && v <= 0)
|
|
591
|
-
return;
|
|
592
|
-
if (v < dMin)
|
|
593
|
-
dMin = v;
|
|
594
|
-
if (v > dMax)
|
|
595
|
-
dMax = v;
|
|
596
|
-
};
|
|
597
|
-
for (const v of extra)
|
|
598
|
-
note(v);
|
|
599
|
-
const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area');
|
|
600
|
-
const lines = list.filter((s) => s.kind === 'line');
|
|
601
|
-
if (stacked && stackable.length) {
|
|
602
|
-
for (let i = 0; i < categories.length; i += 1) {
|
|
603
|
-
let pos = 0;
|
|
604
|
-
let neg = 0;
|
|
605
|
-
for (const s of stackable) {
|
|
606
|
-
const v = s.values[i] ?? 0;
|
|
607
|
-
if (v >= 0)
|
|
608
|
-
pos += v;
|
|
609
|
-
else
|
|
610
|
-
neg += v;
|
|
611
|
-
}
|
|
612
|
-
note(pos);
|
|
613
|
-
note(neg);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
else {
|
|
617
|
-
for (const s of stackable)
|
|
618
|
-
for (const v of s.values)
|
|
619
|
-
note(v);
|
|
620
|
-
}
|
|
621
|
-
for (const s of lines)
|
|
622
|
-
for (const v of s.values)
|
|
623
|
-
note(v);
|
|
624
|
-
// Candles: note the HIGH and the LOW, not `values` (the closes), or every
|
|
625
|
-
// wick clips at the body. Note also that candles are deliberately absent
|
|
626
|
-
// from `stackable`, so the zero-baseline rule below does not fire for them:
|
|
627
|
-
// a price series running 180 to 195 keeps a readable domain instead of being
|
|
628
|
-
// flattened against zero.
|
|
629
|
-
for (const s of list) {
|
|
630
|
-
if (s.kind !== 'candle')
|
|
631
|
-
continue;
|
|
632
|
-
for (const k of s.ohlc ?? []) {
|
|
633
|
-
if (!k)
|
|
634
|
-
continue;
|
|
635
|
-
note(k.h);
|
|
636
|
-
note(k.l);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
// Boxes: the whisker ends and any outlier, for the same reason - `values`
|
|
640
|
-
// holds the medians, so a domain built from those alone would clip half of
|
|
641
|
-
// every box. Boxes are also absent from `stackable`, so a sample that never
|
|
642
|
-
// goes near zero keeps a readable domain.
|
|
643
|
-
for (const s of list) {
|
|
644
|
-
if (s.kind !== 'box')
|
|
62
|
+
export function resolveResponsive(spec, width, height) {
|
|
63
|
+
const hits = matchResponsiveRules(spec.responsive, width, height);
|
|
64
|
+
if (!hits.length)
|
|
65
|
+
return spec;
|
|
66
|
+
let out = spec;
|
|
67
|
+
for (const rule of hits) {
|
|
68
|
+
if (!rule.spec)
|
|
645
69
|
continue;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
for (const o of b.outliers ?? [])
|
|
652
|
-
note(o);
|
|
70
|
+
const { width: _w, height: _h, responsive: _r, ...patch } = rule.spec;
|
|
71
|
+
const merged = { ...out, ...patch };
|
|
72
|
+
for (const k of ['xAxis', 'yAxis', 'y2Axis']) {
|
|
73
|
+
if (patch[k])
|
|
74
|
+
merged[k] = { ...(out[k] ?? {}), ...patch[k] };
|
|
653
75
|
}
|
|
76
|
+
out = merged;
|
|
654
77
|
}
|
|
655
|
-
|
|
656
|
-
// the same defect as a clipped candle wick.
|
|
657
|
-
for (const s of list) {
|
|
658
|
-
if (!s.errors)
|
|
659
|
-
continue;
|
|
660
|
-
s.errors.forEach((e, i) => {
|
|
661
|
-
const span = errorSpan(e, s.values[i] ?? 0);
|
|
662
|
-
if (!span)
|
|
663
|
-
return;
|
|
664
|
-
note(span.lo);
|
|
665
|
-
note(span.hi);
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
if (dMin === Infinity) {
|
|
669
|
-
dMin = isLog ? 1 : 0;
|
|
670
|
-
dMax = isLog ? 10 : 1;
|
|
671
|
-
}
|
|
672
|
-
// Bar / area charts read against a zero baseline, so always include 0
|
|
673
|
-
// - but only on linear axes (0 is invalid in log).
|
|
674
|
-
if (stackable.length && !isLog) {
|
|
675
|
-
dMin = Math.min(dMin, 0);
|
|
676
|
-
dMax = Math.max(dMax, 0);
|
|
677
|
-
}
|
|
678
|
-
return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax);
|
|
78
|
+
return out;
|
|
679
79
|
}
|
|
680
80
|
/**
|
|
681
81
|
* Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
|
|
682
82
|
* ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
|
|
683
83
|
* it runs during SSR and can be unit-tested directly.
|
|
684
84
|
*/
|
|
685
|
-
export function buildChart(
|
|
686
|
-
const width =
|
|
687
|
-
const height =
|
|
85
|
+
export function buildChart(input, theme = 'light') {
|
|
86
|
+
const width = input.width ?? 520;
|
|
87
|
+
const height = input.height ?? 300;
|
|
88
|
+
// Size rules first, so every family sees the patched spec.
|
|
89
|
+
const spec = resolveResponsive(input, width, height);
|
|
90
|
+
// A pareto is a bar chart with a cumulative-share line on the right axis,
|
|
91
|
+
// sorted by value. Build that spec and lay it out as the combo it is.
|
|
92
|
+
if (spec.type === 'pareto')
|
|
93
|
+
return buildChart(paretoSpec(spec), theme);
|
|
688
94
|
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
689
95
|
const stacked = !!(spec.stacked || spec.stacked100);
|
|
690
96
|
const series = spec.series.map((s, i) => ({
|
|
691
97
|
...s,
|
|
98
|
+
// A null is a gap unless the series (or the spec) says it is a zero. Done
|
|
99
|
+
// here, once, so every family and every overlay sees the same numbers.
|
|
100
|
+
values: (s.nullAs ?? spec.nullAs) === 'zero' ? s.values.map((v) => (Number.isFinite(v) ? v : 0)) : s.values,
|
|
692
101
|
color: s.color ?? palette[i % palette.length],
|
|
693
102
|
kind: kindOf(s, spec.type),
|
|
694
103
|
axis: s.axis ?? 'left',
|
|
695
104
|
}));
|
|
105
|
+
const frame = chartFrame(spec, width, height);
|
|
106
|
+
const axes = resolveAxes(spec);
|
|
696
107
|
const legend = series.map((s) => ({ label: s.label, color: s.color }));
|
|
697
108
|
const empty = {
|
|
698
109
|
type: spec.type,
|
|
@@ -700,10 +111,21 @@ export function buildChart(spec, theme = 'light') {
|
|
|
700
111
|
height,
|
|
701
112
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
702
113
|
axes: null,
|
|
114
|
+
frame,
|
|
115
|
+
grid: { x: false, y: false },
|
|
116
|
+
referenceBands: [],
|
|
703
117
|
bars: [],
|
|
704
118
|
candles: [],
|
|
705
119
|
boxes: [],
|
|
706
120
|
errorBars: [],
|
|
121
|
+
stems: [],
|
|
122
|
+
arcs: [],
|
|
123
|
+
chordRibbons: [],
|
|
124
|
+
bullets: [],
|
|
125
|
+
drawings: [],
|
|
126
|
+
seriesLabels: [],
|
|
127
|
+
polarAxes: [],
|
|
128
|
+
polarRings: [],
|
|
707
129
|
lines: [],
|
|
708
130
|
slices: [],
|
|
709
131
|
yTicks: [],
|
|
@@ -712,6 +134,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
712
134
|
xTicks: [],
|
|
713
135
|
categoryGroupTicks: [],
|
|
714
136
|
xLabelRotated: false,
|
|
137
|
+
xLabelAngle: 0,
|
|
715
138
|
legend,
|
|
716
139
|
donut: null,
|
|
717
140
|
referenceLines: [],
|
|
@@ -739,1486 +162,43 @@ export function buildChart(spec, theme = 'light') {
|
|
|
739
162
|
sankeyNodes: [],
|
|
740
163
|
sankeyLinks: [],
|
|
741
164
|
};
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (spec.type === '
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
else {
|
|
775
|
-
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false });
|
|
776
|
-
cum += v;
|
|
777
|
-
}
|
|
778
|
-
});
|
|
779
|
-
// Y-axis domain spans every visited level (including 0).
|
|
780
|
-
let dMin = 0, dMax = 0;
|
|
781
|
-
for (const p of pairs) {
|
|
782
|
-
if (p.from < dMin)
|
|
783
|
-
dMin = p.from;
|
|
784
|
-
if (p.to < dMin)
|
|
785
|
-
dMin = p.to;
|
|
786
|
-
if (p.from > dMax)
|
|
787
|
-
dMax = p.from;
|
|
788
|
-
if (p.to > dMax)
|
|
789
|
-
dMax = p.to;
|
|
790
|
-
}
|
|
791
|
-
const dom = niceScale(dMin, dMax);
|
|
792
|
-
const yOfW = (v) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH);
|
|
793
|
-
const slotW = plotW / Math.max(1, spec.categories.length);
|
|
794
|
-
const barPad = slotW * 0.2;
|
|
795
|
-
const barW = Math.max(1, slotW - barPad);
|
|
796
|
-
const bars = pairs.map((p, i) => {
|
|
797
|
-
const yTop = yOfW(Math.max(p.from, p.to));
|
|
798
|
-
const yBot = yOfW(Math.min(p.from, p.to));
|
|
799
|
-
const x = padL + slotW * i + barPad / 2;
|
|
800
|
-
const color = p.isTotal ? total : p.value >= 0 ? positive : negative;
|
|
801
|
-
return {
|
|
802
|
-
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
803
|
-
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
|
|
804
|
-
};
|
|
805
|
-
});
|
|
806
|
-
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
807
|
-
const connectors = [{
|
|
808
|
-
path: pairs
|
|
809
|
-
.map((p, i) => {
|
|
810
|
-
const x0 = padL + slotW * i + barPad / 2 + barW;
|
|
811
|
-
const y = yOfW(p.to);
|
|
812
|
-
const x1 = padL + slotW * (i + 1) + barPad / 2;
|
|
813
|
-
// Skip the final connector beyond the last bar.
|
|
814
|
-
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : '';
|
|
815
|
-
})
|
|
816
|
-
.filter(Boolean)
|
|
817
|
-
.join(' '),
|
|
818
|
-
areaPath: '',
|
|
819
|
-
color: 'var(--sg-muted, #94a3b8)',
|
|
820
|
-
label: '',
|
|
821
|
-
points: [],
|
|
822
|
-
}];
|
|
823
|
-
const xTicks = spec.categories.map((label, i) => ({
|
|
824
|
-
label,
|
|
825
|
-
x: round(padL + slotW * i + slotW / 2),
|
|
826
|
-
}));
|
|
827
|
-
const yTicks = dom.ticks.map((value) => ({
|
|
828
|
-
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
|
|
829
|
-
}));
|
|
830
|
-
return {
|
|
831
|
-
...empty,
|
|
832
|
-
plot,
|
|
833
|
-
bars,
|
|
834
|
-
lines: connectors,
|
|
835
|
-
yTicks,
|
|
836
|
-
xTicks,
|
|
837
|
-
xLabelRotated,
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
// ---- Funnel -------------------------------------------------------
|
|
841
|
-
// One series of strictly-decreasing values gets rendered as a stack
|
|
842
|
-
// of horizontal trapezoids: each level's width is proportional to its
|
|
843
|
-
// value relative to the largest, slope automatically links level N+1
|
|
844
|
-
// narrower than level N. Labels show value, conversion vs. top, and
|
|
845
|
-
// step drop-off.
|
|
846
|
-
if (spec.type === 'funnel') {
|
|
847
|
-
const src = series[0];
|
|
848
|
-
if (!src || !src.values.length)
|
|
849
|
-
return { ...empty };
|
|
850
|
-
const padL = 20, padR = 20, padT = 16, padB = 16;
|
|
851
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
852
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
853
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
854
|
-
const n = src.values.length;
|
|
855
|
-
const stepH = plotH / n;
|
|
856
|
-
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)));
|
|
857
|
-
const top = src.values[0] ?? 0;
|
|
858
|
-
const widthAt = (v) => (valMax > 0 ? (v / valMax) * plotW : 0);
|
|
859
|
-
const cx = padL + plotW / 2;
|
|
860
|
-
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
861
|
-
const segments = src.values.map((v, i) => {
|
|
862
|
-
const next = src.values[i + 1] ?? v * 0.8; // taper to a point on the last level
|
|
863
|
-
const w0 = widthAt(v);
|
|
864
|
-
const w1 = widthAt(next);
|
|
865
|
-
const y0 = padT + stepH * i;
|
|
866
|
-
const y1 = y0 + stepH;
|
|
867
|
-
const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`;
|
|
868
|
-
const color = src.color ?? palette[i % palette.length];
|
|
869
|
-
return {
|
|
870
|
-
path, color,
|
|
871
|
-
label: spec.categories[i] ?? src.label,
|
|
872
|
-
value: v,
|
|
873
|
-
conversion: top > 0 ? v / top : 0,
|
|
874
|
-
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
875
|
-
cx,
|
|
876
|
-
cy: (y0 + y1) / 2,
|
|
877
|
-
textColor: pickContrastText(color),
|
|
878
|
-
};
|
|
879
|
-
});
|
|
880
|
-
return {
|
|
881
|
-
...empty,
|
|
882
|
-
plot,
|
|
883
|
-
funnelSegments: segments,
|
|
884
|
-
};
|
|
885
|
-
}
|
|
886
|
-
// ---- Radar --------------------------------------------------------
|
|
887
|
-
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
888
|
-
// contributes a polygon connecting its values across the spokes. All
|
|
889
|
-
// series share the same scale (max across every value). Concentric
|
|
890
|
-
// ring count derived from data, capped at 5 for legibility.
|
|
891
|
-
if (spec.type === 'radar') {
|
|
892
|
-
if (!series.length || !spec.categories.length)
|
|
893
|
-
return { ...empty };
|
|
894
|
-
const padL = 30, padR = 30, padT = 24, padB = 24;
|
|
895
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
896
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
897
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
898
|
-
const cx = padL + plotW / 2;
|
|
899
|
-
const cy = padT + plotH / 2;
|
|
900
|
-
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20);
|
|
901
|
-
const axes = spec.categories;
|
|
902
|
-
const k = axes.length;
|
|
903
|
-
let vMax = 0;
|
|
904
|
-
for (const s of series)
|
|
905
|
-
for (const v of s.values) {
|
|
906
|
-
if (Number.isFinite(v) && v > vMax)
|
|
907
|
-
vMax = v;
|
|
908
|
-
}
|
|
909
|
-
if (vMax === 0)
|
|
910
|
-
vMax = 1;
|
|
911
|
-
const ringCount = 5;
|
|
912
|
-
const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax);
|
|
913
|
-
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
914
|
-
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
915
|
-
const angleAt = (i) => -Math.PI / 2 + (i / k) * Math.PI * 2;
|
|
916
|
-
const pointAt = (i, v) => {
|
|
917
|
-
const t = v / vMax;
|
|
918
|
-
const a = angleAt(i);
|
|
919
|
-
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) };
|
|
920
|
-
};
|
|
921
|
-
const radarAxes = axes.map((label, i) => {
|
|
922
|
-
const p = pointAt(i, vMax);
|
|
923
|
-
return { label, x: p.x, y: p.y };
|
|
924
|
-
});
|
|
925
|
-
const radarSeriesGeo = series.map((s, si) => {
|
|
926
|
-
const pts = s.values.map((v, i) => {
|
|
927
|
-
const safe = Number.isFinite(v) ? v : 0;
|
|
928
|
-
const p = pointAt(i, safe);
|
|
929
|
-
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' };
|
|
930
|
-
});
|
|
931
|
-
const path = pts.length
|
|
932
|
-
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
933
|
-
: '';
|
|
934
|
-
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
935
|
-
return { label: s.label, color: s.color ?? palette[si % palette.length], path, points: pts };
|
|
936
|
-
});
|
|
937
|
-
return {
|
|
938
|
-
...empty,
|
|
939
|
-
plot,
|
|
940
|
-
radarRings: ringValues,
|
|
941
|
-
radarAxes,
|
|
942
|
-
radarSeries: radarSeriesGeo,
|
|
943
|
-
radarCenter: { cx, cy, r },
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
// ---- Calendar heatmap --------------------------------------------
|
|
947
|
-
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
948
|
-
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
949
|
-
// sequential color scale. Days with no value render blank (border only)
|
|
950
|
-
// so missing data is visually obvious.
|
|
951
|
-
if (spec.type === 'calendar') {
|
|
952
|
-
const values = spec.calendarValues ?? [];
|
|
953
|
-
if (!values.length && !spec.calendarStart)
|
|
954
|
-
return { ...empty };
|
|
955
|
-
// Build a value lookup + figure out the date range.
|
|
956
|
-
const valueByDate = new Map();
|
|
957
|
-
let vMin = Infinity, vMax = -Infinity;
|
|
958
|
-
for (const v of values) {
|
|
959
|
-
valueByDate.set(v.date, v.value);
|
|
960
|
-
if (Number.isFinite(v.value)) {
|
|
961
|
-
if (v.value < vMin)
|
|
962
|
-
vMin = v.value;
|
|
963
|
-
if (v.value > vMax)
|
|
964
|
-
vMax = v.value;
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
if (vMin === Infinity) {
|
|
968
|
-
vMin = 0;
|
|
969
|
-
vMax = 1;
|
|
970
|
-
}
|
|
971
|
-
if (vMin === vMax)
|
|
972
|
-
vMax = vMin + 1;
|
|
973
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
|
|
974
|
-
const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
|
|
975
|
-
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
976
|
-
// span the data + round to whole weeks (Sun..Sat).
|
|
977
|
-
const sorted = values.map((v) => v.date).sort();
|
|
978
|
-
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01';
|
|
979
|
-
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr;
|
|
980
|
-
const start = new Date(startStr + 'T00:00:00Z');
|
|
981
|
-
const end = new Date(endStr + 'T00:00:00Z');
|
|
982
|
-
// Roll start back to its Sunday, end forward to its Saturday.
|
|
983
|
-
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
984
|
-
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
|
985
|
-
const totalDays = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
|
986
|
-
const weeks = Math.ceil(totalDays / 7);
|
|
987
|
-
const padL = 36, padR = 80, padT = 26, padB = 16;
|
|
988
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
989
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
990
|
-
// Cell size: fit weeks across width, 7 rows down height.
|
|
991
|
-
const cellW = Math.floor(plotW / weeks);
|
|
992
|
-
const cellH = Math.floor(plotH / 7);
|
|
993
|
-
const cellSize = Math.max(6, Math.min(cellW, cellH));
|
|
994
|
-
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 };
|
|
995
|
-
const cells = [];
|
|
996
|
-
let lastMonth = -1;
|
|
997
|
-
const monthTicks = [];
|
|
998
|
-
for (let i = 0; i < totalDays; i += 1) {
|
|
999
|
-
const day = new Date(start.getTime() + i * 86400000);
|
|
1000
|
-
const col = Math.floor(i / 7);
|
|
1001
|
-
const row = i % 7;
|
|
1002
|
-
const date = day.toISOString().slice(0, 10);
|
|
1003
|
-
const has = valueByDate.has(date);
|
|
1004
|
-
const v = valueByDate.get(date) ?? 0;
|
|
1005
|
-
cells.push({
|
|
1006
|
-
x: padL + col * cellSize,
|
|
1007
|
-
y: padT + row * cellSize,
|
|
1008
|
-
size: cellSize,
|
|
1009
|
-
date, value: v,
|
|
1010
|
-
defined: has,
|
|
1011
|
-
color: has ? colorAt(v) : 'transparent',
|
|
1012
|
-
});
|
|
1013
|
-
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
|
|
1014
|
-
lastMonth = day.getUTCMonth();
|
|
1015
|
-
monthTicks.push({
|
|
1016
|
-
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
1017
|
-
x: padL + col * cellSize,
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
1022
|
-
const t = i / 4;
|
|
1023
|
-
const value = vMin + (vMax - vMin) * t;
|
|
1024
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
|
|
1025
|
-
});
|
|
1026
|
-
return {
|
|
1027
|
-
...empty,
|
|
1028
|
-
plot,
|
|
1029
|
-
calendarCells: cells,
|
|
1030
|
-
calendarMonthTicks: monthTicks,
|
|
1031
|
-
calendarLegend: legend,
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
1034
|
-
// ---- Gauge --------------------------------------------------------
|
|
1035
|
-
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
1036
|
-
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
1037
|
-
if (spec.type === 'gauge') {
|
|
1038
|
-
const min = spec.gaugeMin ?? 0;
|
|
1039
|
-
const max = spec.gaugeMax ?? 100;
|
|
1040
|
-
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0));
|
|
1041
|
-
const target = spec.gaugeTarget;
|
|
1042
|
-
const cx = width / 2;
|
|
1043
|
-
const cy = height * 0.78;
|
|
1044
|
-
const r = Math.min(width * 0.42, height * 0.65);
|
|
1045
|
-
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
1046
|
-
const A0 = Math.PI;
|
|
1047
|
-
const A1 = 2 * Math.PI;
|
|
1048
|
-
const angleAt = (v) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0);
|
|
1049
|
-
const arc = (a0, a1, radius) => {
|
|
1050
|
-
const x1 = cx + radius * Math.cos(a0);
|
|
1051
|
-
const y1 = cy + radius * Math.sin(a0);
|
|
1052
|
-
const x2 = cx + radius * Math.cos(a1);
|
|
1053
|
-
const y2 = cy + radius * Math.sin(a1);
|
|
1054
|
-
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
1055
|
-
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`;
|
|
1056
|
-
};
|
|
1057
|
-
const trackPath = arc(A0, A1, r);
|
|
1058
|
-
const valuePath = arc(A0, angleAt(value), r);
|
|
1059
|
-
// Bands sit on their own inner ring, well clear of the value arc's 16px
|
|
1060
|
-
// stroke at `r`. They are context, not the reading: a band covering most
|
|
1061
|
-
// of the scale (an error-rate dial where anything above 0.45 is red) used
|
|
1062
|
-
// to out-shout the value arc completely, so the dial looked pegged at
|
|
1063
|
-
// maximum when the actual value was 9 percent.
|
|
1064
|
-
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
1065
|
-
path: arc(angleAt(band.from), angleAt(band.to), r - 16),
|
|
1066
|
-
color: band.color, from: band.from, to: band.to,
|
|
1067
|
-
}));
|
|
1068
|
-
let targetPx = null;
|
|
1069
|
-
if (target != null && Number.isFinite(target)) {
|
|
1070
|
-
const a = angleAt(Math.max(min, Math.min(max, target)));
|
|
1071
|
-
const inner = r - 12;
|
|
1072
|
-
const outer = r + 4;
|
|
1073
|
-
targetPx = {
|
|
1074
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1075
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
1079
|
-
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
1080
|
-
const ticks = [];
|
|
1081
|
-
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR;
|
|
1082
|
-
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
1083
|
-
const a = A0 + (i / TICK_TOTAL) * (A1 - A0);
|
|
1084
|
-
const major = i % TICK_MINOR === 0;
|
|
1085
|
-
const inner = r + 9;
|
|
1086
|
-
const outer = r + (major ? 17 : 13);
|
|
1087
|
-
ticks.push({
|
|
1088
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1089
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1090
|
-
major,
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
1094
|
-
// tail) pivoting on a center hub.
|
|
1095
|
-
//
|
|
1096
|
-
// The proportions matter more than they look, and the counterweight was the
|
|
1097
|
-
// whole problem. A tail sticking out past the hub is a short, solid triangle;
|
|
1098
|
-
// the pointer is a 130px taper that thins to nothing. The compact shape wins
|
|
1099
|
-
// the eye, so on a near-full dial the needle read as an arrow pointing at the
|
|
1100
|
-
// MINIMUM. Keeping the tail inside the hub radius removes the competing
|
|
1101
|
-
// point without giving up the pivot, and the shoulders sit outside the hub so
|
|
1102
|
-
// the needle actually has a visible base to taper from.
|
|
1103
|
-
const aV = angleAt(value);
|
|
1104
|
-
const tipR = r - 14, tailR = 5, baseR = 9;
|
|
1105
|
-
const aPerp = aV + Math.PI / 2;
|
|
1106
|
-
const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
|
|
1107
|
-
const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
|
|
1108
|
-
// Color the value arc by the band the value currently sits in.
|
|
1109
|
-
// First match wins, and bands are half-open [from, to). Bands normally
|
|
1110
|
-
// share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
|
|
1111
|
-
// an inclusive `to` plus last-match-wins, a value sitting exactly ON a
|
|
1112
|
-
// boundary took the colour of the band ABOVE it. An error rate of 0.45
|
|
1113
|
-
// against a 0.45 amber ceiling read as red.
|
|
1114
|
-
let valueColor = null;
|
|
1115
|
-
const bands = spec.gaugeRanges ?? [];
|
|
1116
|
-
for (const band of bands) {
|
|
1117
|
-
if (value >= band.from && value < band.to) {
|
|
1118
|
-
valueColor = band.color;
|
|
1119
|
-
break;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
// The very top of the scale belongs to the last band that reaches it,
|
|
1123
|
-
// which the half-open test above would otherwise exclude.
|
|
1124
|
-
if (valueColor == null) {
|
|
1125
|
-
for (const band of bands)
|
|
1126
|
-
if (value >= band.from && value <= band.to)
|
|
1127
|
-
valueColor = band.color;
|
|
1128
|
-
}
|
|
1129
|
-
return {
|
|
1130
|
-
...empty,
|
|
1131
|
-
plot: { x: 0, y: 0, w: width, h: height },
|
|
1132
|
-
gauge: {
|
|
1133
|
-
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
1134
|
-
ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
|
|
1135
|
-
minLabel: { x: cx - r, y: cy + 20 },
|
|
1136
|
-
maxLabel: { x: cx + r, y: cy + 20 },
|
|
1137
|
-
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
1138
|
-
},
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
// ---- Tree-map -----------------------------------------------------
|
|
1142
|
-
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
1143
|
-
// partitions its rectangle in proportion to its children, picking the
|
|
1144
|
-
// split orientation that keeps aspect ratios closest to 1.
|
|
1145
|
-
if (spec.type === 'treemap') {
|
|
1146
|
-
const root = spec.treemap;
|
|
1147
|
-
if (!root)
|
|
1148
|
-
return { ...empty };
|
|
1149
|
-
const padL = 4, padR = 4, padT = 4, padB = 4;
|
|
1150
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1151
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1152
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1153
|
-
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
1154
|
-
const cells = [];
|
|
1155
|
-
function totalOf(n) {
|
|
1156
|
-
if (n.children?.length)
|
|
1157
|
-
return n.children.reduce((s, c) => s + totalOf(c), 0);
|
|
1158
|
-
return Math.max(0, n.value ?? 0);
|
|
1159
|
-
}
|
|
1160
|
-
function squarify(items, x, y, w, h, depth) {
|
|
1161
|
-
if (!items.length || w <= 0 || h <= 0)
|
|
1162
|
-
return;
|
|
1163
|
-
const totals = items.map(totalOf);
|
|
1164
|
-
const sum = totals.reduce((a, b) => a + b, 0);
|
|
1165
|
-
if (sum <= 0)
|
|
1166
|
-
return;
|
|
1167
|
-
// Process largest-first so big items dominate the first row.
|
|
1168
|
-
const ordered = items
|
|
1169
|
-
.map((n, i) => ({ node: n, value: totals[i] }))
|
|
1170
|
-
.sort((a, b) => b.value - a.value);
|
|
1171
|
-
let cx = x, cy = y, cw = w, ch = h, remaining = sum;
|
|
1172
|
-
let row = [];
|
|
1173
|
-
const worstRatio = (vals, shortSide, rowSum, scale) => {
|
|
1174
|
-
if (rowSum <= 0)
|
|
1175
|
-
return Infinity;
|
|
1176
|
-
const rowArea = rowSum * scale;
|
|
1177
|
-
const rowSide = rowArea / shortSide;
|
|
1178
|
-
let worst = 0;
|
|
1179
|
-
for (const v of vals) {
|
|
1180
|
-
const cell = v * scale;
|
|
1181
|
-
const long = cell / rowSide;
|
|
1182
|
-
const r = Math.max(shortSide / long, long / shortSide);
|
|
1183
|
-
if (r > worst)
|
|
1184
|
-
worst = r;
|
|
1185
|
-
}
|
|
1186
|
-
return worst;
|
|
1187
|
-
};
|
|
1188
|
-
function flushRow() {
|
|
1189
|
-
if (!row.length)
|
|
1190
|
-
return;
|
|
1191
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0);
|
|
1192
|
-
const scale = (cw * ch) / remaining;
|
|
1193
|
-
const horizontal = cw >= ch;
|
|
1194
|
-
const shortSide = horizontal ? ch : cw;
|
|
1195
|
-
const rowSide = (rowSum * scale) / shortSide;
|
|
1196
|
-
let offset = 0;
|
|
1197
|
-
for (const it of row) {
|
|
1198
|
-
const cellSize = (it.value * scale) / rowSide;
|
|
1199
|
-
const cx2 = horizontal ? cx : cx + offset;
|
|
1200
|
-
const cy2 = horizontal ? cy + offset : cy;
|
|
1201
|
-
const ww = horizontal ? rowSide : cellSize;
|
|
1202
|
-
const hh = horizontal ? cellSize : rowSide;
|
|
1203
|
-
const color = it.node.color ?? palette[(depth + cells.length) % palette.length];
|
|
1204
|
-
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
1205
|
-
if (it.node.children?.length) {
|
|
1206
|
-
cells.push({
|
|
1207
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1208
|
-
color, textColor: pickContrastText(color),
|
|
1209
|
-
name: it.node.name, value: it.value, depth,
|
|
1210
|
-
});
|
|
1211
|
-
const labelH = Math.min(18, hh * 0.25);
|
|
1212
|
-
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1);
|
|
1213
|
-
}
|
|
1214
|
-
else {
|
|
1215
|
-
cells.push({
|
|
1216
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1217
|
-
color, textColor: pickContrastText(color),
|
|
1218
|
-
name: it.node.name, value: it.value, depth,
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
offset += cellSize;
|
|
1222
|
-
}
|
|
1223
|
-
// Shrink the remaining strip.
|
|
1224
|
-
if (horizontal) {
|
|
1225
|
-
cx += rowSide;
|
|
1226
|
-
cw -= rowSide;
|
|
1227
|
-
}
|
|
1228
|
-
else {
|
|
1229
|
-
cy += rowSide;
|
|
1230
|
-
ch -= rowSide;
|
|
1231
|
-
}
|
|
1232
|
-
remaining -= rowSum;
|
|
1233
|
-
row = [];
|
|
1234
|
-
}
|
|
1235
|
-
for (const it of ordered) {
|
|
1236
|
-
const scale = (cw * ch) / remaining;
|
|
1237
|
-
const shortSide = Math.min(cw, ch);
|
|
1238
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0);
|
|
1239
|
-
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale);
|
|
1240
|
-
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale);
|
|
1241
|
-
if (row.length && nextWorst > currWorst) {
|
|
1242
|
-
flushRow();
|
|
1243
|
-
}
|
|
1244
|
-
row.push(it);
|
|
1245
|
-
}
|
|
1246
|
-
flushRow();
|
|
1247
|
-
}
|
|
1248
|
-
const seedItems = root.children ?? [root];
|
|
1249
|
-
squarify(seedItems, padL, padT, plotW, plotH, 0);
|
|
1250
|
-
return { ...empty, plot, treemapCells: cells };
|
|
1251
|
-
}
|
|
1252
|
-
// ---- Sankey -------------------------------------------------------
|
|
1253
|
-
// Multi-column flow layout. Each node assigned to a column by longest
|
|
1254
|
-
// path from any source. Within a column, nodes are stacked vertically;
|
|
1255
|
-
// height proportional to max(totalIn, totalOut). Links render as
|
|
1256
|
-
// bezier ribbons whose width is the link value (in pixels).
|
|
1257
|
-
if (spec.type === 'sankey') {
|
|
1258
|
-
const nodes = spec.sankeyNodes ?? [];
|
|
1259
|
-
const links = spec.sankeyLinks ?? [];
|
|
1260
|
-
if (!nodes.length || !links.length)
|
|
1261
|
-
return { ...empty };
|
|
1262
|
-
const padL = 10, padR = 10, padT = 14, padB = 14;
|
|
1263
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1264
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1265
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1266
|
-
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
1267
|
-
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
1268
|
-
// Column = longest path from any node with no incoming edges.
|
|
1269
|
-
const targets = new Set(links.map((l) => l.target));
|
|
1270
|
-
const sources = nodes.filter((n) => !targets.has(n.id));
|
|
1271
|
-
const column = new Map();
|
|
1272
|
-
function visit(id, depth, seen) {
|
|
1273
|
-
if (seen.has(id))
|
|
1274
|
-
return;
|
|
1275
|
-
seen.add(id);
|
|
1276
|
-
const cur = column.get(id) ?? 0;
|
|
1277
|
-
if (depth > cur || !column.has(id))
|
|
1278
|
-
column.set(id, depth);
|
|
1279
|
-
for (const l of links)
|
|
1280
|
-
if (l.source === id)
|
|
1281
|
-
visit(l.target, depth + 1, seen);
|
|
1282
|
-
seen.delete(id);
|
|
1283
|
-
}
|
|
1284
|
-
for (const s of sources)
|
|
1285
|
-
visit(s.id, 0, new Set());
|
|
1286
|
-
// Cover any nodes with no path from a source (orphan rings).
|
|
1287
|
-
for (const n of nodes)
|
|
1288
|
-
if (!column.has(n.id))
|
|
1289
|
-
column.set(n.id, 0);
|
|
1290
|
-
const maxCol = Math.max(...column.values());
|
|
1291
|
-
const cols = maxCol + 1;
|
|
1292
|
-
const nodeW = 14;
|
|
1293
|
-
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0;
|
|
1294
|
-
// Totals per node.
|
|
1295
|
-
const totalIn = new Map();
|
|
1296
|
-
const totalOut = new Map();
|
|
1297
|
-
for (const l of links) {
|
|
1298
|
-
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value);
|
|
1299
|
-
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value);
|
|
1300
|
-
}
|
|
1301
|
-
// Per-column groups + max total in that column.
|
|
1302
|
-
const byCol = new Map();
|
|
1303
|
-
for (const n of nodes) {
|
|
1304
|
-
const c = column.get(n.id) ?? 0;
|
|
1305
|
-
const arr = byCol.get(c) ?? [];
|
|
1306
|
-
arr.push(n.id);
|
|
1307
|
-
byCol.set(c, arr);
|
|
1308
|
-
}
|
|
1309
|
-
// Per-column total height + node height scale.
|
|
1310
|
-
let maxColTotal = 0;
|
|
1311
|
-
for (const ids of byCol.values()) {
|
|
1312
|
-
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0);
|
|
1313
|
-
if (t > maxColTotal)
|
|
1314
|
-
maxColTotal = t;
|
|
1315
|
-
}
|
|
1316
|
-
if (maxColTotal === 0)
|
|
1317
|
-
return { ...empty, plot };
|
|
1318
|
-
const nodeGapPx = 8;
|
|
1319
|
-
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal; // leave gap room
|
|
1320
|
-
const placed = [];
|
|
1321
|
-
for (const [c, ids] of byCol) {
|
|
1322
|
-
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale));
|
|
1323
|
-
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1);
|
|
1324
|
-
let yCursor = padT + (plotH - totalH) / 2;
|
|
1325
|
-
const xCol = padL + c * (nodeW + gapBetweenColumns);
|
|
1326
|
-
ids.forEach((id, idx) => {
|
|
1327
|
-
const node = nodeById.get(id);
|
|
1328
|
-
const h = heights[idx];
|
|
1329
|
-
placed.push({
|
|
1330
|
-
id,
|
|
1331
|
-
label: node.label ?? id,
|
|
1332
|
-
color: node.color ?? palette[(placed.length) % palette.length],
|
|
1333
|
-
x: xCol, y: yCursor, w: nodeW, h,
|
|
1334
|
-
column: c,
|
|
1335
|
-
totalIn: totalIn.get(id) ?? 0,
|
|
1336
|
-
totalOut: totalOut.get(id) ?? 0,
|
|
1337
|
-
});
|
|
1338
|
-
yCursor += h + nodeGapPx;
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
const placedById = new Map(placed.map((n) => [n.id, n]));
|
|
1342
|
-
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
1343
|
-
const inCursor = new Map();
|
|
1344
|
-
const outCursor = new Map();
|
|
1345
|
-
const builtLinks = [];
|
|
1346
|
-
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
1347
|
-
const sortedLinks = links.slice().sort((a, b) => b.value - a.value);
|
|
1348
|
-
for (const link of sortedLinks) {
|
|
1349
|
-
const a = placedById.get(link.source);
|
|
1350
|
-
const b = placedById.get(link.target);
|
|
1351
|
-
if (!a || !b)
|
|
1352
|
-
continue;
|
|
1353
|
-
const linkH = Math.max(1, link.value * heightScale);
|
|
1354
|
-
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2;
|
|
1355
|
-
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2;
|
|
1356
|
-
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH);
|
|
1357
|
-
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH);
|
|
1358
|
-
const x0 = a.x + a.w;
|
|
1359
|
-
const x1 = b.x;
|
|
1360
|
-
const mid = (x0 + x1) / 2;
|
|
1361
|
-
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`;
|
|
1362
|
-
builtLinks.push({
|
|
1363
|
-
path, color: link.color ?? a.color, width: linkH,
|
|
1364
|
-
source: link.source, target: link.target, value: link.value,
|
|
1365
|
-
});
|
|
1366
|
-
}
|
|
1367
|
-
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks };
|
|
1368
|
-
}
|
|
1369
|
-
// ---- Heatmap ------------------------------------------------------
|
|
1370
|
-
// Each series is one row, series.values are the cells across categories.
|
|
1371
|
-
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
1372
|
-
// global value range. Cell text contrasts black/white against the cell.
|
|
1373
|
-
if (spec.type === 'heatmap') {
|
|
1374
|
-
if (!series.length || !spec.categories.length)
|
|
1375
|
-
return { ...empty, plot: { x: 0, y: 0, w: width, h: height } };
|
|
1376
|
-
// Layout: left gutter for row labels, bottom for column labels.
|
|
1377
|
-
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0);
|
|
1378
|
-
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7));
|
|
1379
|
-
const padR = 64; // room for the right-side legend bar
|
|
1380
|
-
const padT = 12;
|
|
1381
|
-
const padB = 32;
|
|
1382
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1383
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1384
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1385
|
-
const cellW = plotW / spec.categories.length;
|
|
1386
|
-
const cellH = plotH / series.length;
|
|
1387
|
-
// Resolve value range across the whole matrix.
|
|
1388
|
-
let vMin = Infinity, vMax = -Infinity;
|
|
1389
|
-
for (const s of series)
|
|
1390
|
-
for (const v of s.values) {
|
|
1391
|
-
if (!Number.isFinite(v))
|
|
1392
|
-
continue;
|
|
1393
|
-
if (v < vMin)
|
|
1394
|
-
vMin = v;
|
|
1395
|
-
if (v > vMax)
|
|
1396
|
-
vMax = v;
|
|
1397
|
-
}
|
|
1398
|
-
if (vMin === Infinity) {
|
|
1399
|
-
vMin = 0;
|
|
1400
|
-
vMax = 1;
|
|
1401
|
-
}
|
|
1402
|
-
if (vMin === vMax)
|
|
1403
|
-
vMax = vMin + 1;
|
|
1404
|
-
// Pick the palette stops.
|
|
1405
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
|
|
1406
|
-
const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
|
|
1407
|
-
const heatmapCells = [];
|
|
1408
|
-
series.forEach((s, ri) => {
|
|
1409
|
-
s.values.forEach((v, ci) => {
|
|
1410
|
-
if (!Number.isFinite(v))
|
|
1411
|
-
return;
|
|
1412
|
-
const color = colorAt(v);
|
|
1413
|
-
heatmapCells.push({
|
|
1414
|
-
x: round(padL + cellW * ci),
|
|
1415
|
-
y: round(padT + cellH * ri),
|
|
1416
|
-
w: round(cellW),
|
|
1417
|
-
h: round(cellH),
|
|
1418
|
-
color,
|
|
1419
|
-
textColor: pickContrastText(color),
|
|
1420
|
-
value: v,
|
|
1421
|
-
rowLabel: s.label,
|
|
1422
|
-
colLabel: spec.categories[ci] ?? '',
|
|
1423
|
-
});
|
|
1424
|
-
});
|
|
1425
|
-
});
|
|
1426
|
-
const heatmapRowTicks = series.map((s, i) => ({
|
|
1427
|
-
value: i,
|
|
1428
|
-
y: round(padT + cellH * i + cellH / 2),
|
|
1429
|
-
label: s.label,
|
|
1430
|
-
}));
|
|
1431
|
-
const heatmapColTicks = spec.categories.map((label, i) => ({
|
|
1432
|
-
label,
|
|
1433
|
-
x: round(padL + cellW * i + cellW / 2),
|
|
1434
|
-
}));
|
|
1435
|
-
// Legend: sample 5 stops across the range.
|
|
1436
|
-
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1437
|
-
const t = i / 4;
|
|
1438
|
-
const value = vMin + (vMax - vMin) * t;
|
|
1439
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
|
|
1440
|
-
});
|
|
1441
|
-
return {
|
|
1442
|
-
...empty,
|
|
1443
|
-
plot,
|
|
1444
|
-
heatmapCells,
|
|
1445
|
-
heatmapRowTicks,
|
|
1446
|
-
heatmapColTicks,
|
|
1447
|
-
heatmapLegend,
|
|
1448
|
-
};
|
|
1449
|
-
}
|
|
1450
|
-
if (spec.type === 'pie') {
|
|
1451
|
-
const s = series[0];
|
|
1452
|
-
if (!s)
|
|
1453
|
-
return empty;
|
|
1454
|
-
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1;
|
|
1455
|
-
const cx = width / 2;
|
|
1456
|
-
const cy = height / 2;
|
|
1457
|
-
const r = Math.min(width, height) / 2 - 10;
|
|
1458
|
-
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0));
|
|
1459
|
-
const ir = r * innerFrac;
|
|
1460
|
-
let angle = -Math.PI / 2;
|
|
1461
|
-
const slices = s.values.map((v, i) => {
|
|
1462
|
-
const frac = Math.max(0, v) / total;
|
|
1463
|
-
const a0 = angle;
|
|
1464
|
-
const a1 = angle + frac * Math.PI * 2;
|
|
1465
|
-
angle = a1;
|
|
1466
|
-
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
1467
|
-
const mid = (a0 + a1) / 2;
|
|
1468
|
-
const labelR = (r + ir) / 2 || r * 0.6;
|
|
1469
|
-
const ox0 = cx + r * Math.cos(a0);
|
|
1470
|
-
const oy0 = cy + r * Math.sin(a0);
|
|
1471
|
-
const ox1 = cx + r * Math.cos(a1);
|
|
1472
|
-
const oy1 = cy + r * Math.sin(a1);
|
|
1473
|
-
let path;
|
|
1474
|
-
if (frac >= 0.999) {
|
|
1475
|
-
path = ir
|
|
1476
|
-
? `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` +
|
|
1477
|
-
`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`
|
|
1478
|
-
: `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`;
|
|
1479
|
-
}
|
|
1480
|
-
else if (ir > 0) {
|
|
1481
|
-
const ix0 = cx + ir * Math.cos(a0);
|
|
1482
|
-
const iy0 = cy + ir * Math.sin(a0);
|
|
1483
|
-
const ix1 = cx + ir * Math.cos(a1);
|
|
1484
|
-
const iy1 = cy + ir * Math.sin(a1);
|
|
1485
|
-
path =
|
|
1486
|
-
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
1487
|
-
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`;
|
|
1488
|
-
}
|
|
1489
|
-
else {
|
|
1490
|
-
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`;
|
|
1491
|
-
}
|
|
1492
|
-
const catLabel = spec.categories[i] ?? String(i);
|
|
1493
|
-
return {
|
|
1494
|
-
path,
|
|
1495
|
-
color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length],
|
|
1496
|
-
label: catLabel,
|
|
1497
|
-
value: v,
|
|
1498
|
-
percent: frac * 100,
|
|
1499
|
-
cx: round(cx + labelR * Math.cos(mid)),
|
|
1500
|
-
cy: round(cy + labelR * Math.sin(mid)),
|
|
1501
|
-
};
|
|
1502
|
-
});
|
|
1503
|
-
return {
|
|
1504
|
-
...empty,
|
|
1505
|
-
slices,
|
|
1506
|
-
legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length] })),
|
|
1507
|
-
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,
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
if (spec.type === 'scatter') {
|
|
1511
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0);
|
|
1512
|
-
const padR = 12;
|
|
1513
|
-
const padT = 10;
|
|
1514
|
-
const padB = 28 + (spec.xAxisTitle ? 16 : 0);
|
|
1515
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1516
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1517
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1518
|
-
let xMin = Infinity;
|
|
1519
|
-
let xMax = -Infinity;
|
|
1520
|
-
let yMin = Infinity;
|
|
1521
|
-
let yMax = -Infinity;
|
|
1522
|
-
let rMin = Infinity;
|
|
1523
|
-
let rMax = -Infinity;
|
|
1524
|
-
for (const s of series) {
|
|
1525
|
-
for (const pt of s.points ?? []) {
|
|
1526
|
-
if (Number.isFinite(pt.x)) {
|
|
1527
|
-
xMin = Math.min(xMin, pt.x);
|
|
1528
|
-
xMax = Math.max(xMax, pt.x);
|
|
1529
|
-
}
|
|
1530
|
-
if (Number.isFinite(pt.y)) {
|
|
1531
|
-
yMin = Math.min(yMin, pt.y);
|
|
1532
|
-
yMax = Math.max(yMax, pt.y);
|
|
1533
|
-
}
|
|
1534
|
-
if (pt.r != null && Number.isFinite(pt.r)) {
|
|
1535
|
-
rMin = Math.min(rMin, pt.r);
|
|
1536
|
-
rMax = Math.max(rMax, pt.r);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
if (xMin === Infinity)
|
|
1541
|
-
return { ...empty, plot };
|
|
1542
|
-
const xDom = niceScale(xMin, xMax);
|
|
1543
|
-
const yDom = niceScale(yMin, yMax);
|
|
1544
|
-
const hasR = rMax > rMin;
|
|
1545
|
-
const xOf = (v) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW);
|
|
1546
|
-
const yOf = (v) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH);
|
|
1547
|
-
const rOf = (r) => hasR && r != null && Number.isFinite(r)
|
|
1548
|
-
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
1549
|
-
: 5;
|
|
1550
|
-
const scatterPoints = [];
|
|
1551
|
-
for (const s of series) {
|
|
1552
|
-
for (const pt of s.points ?? []) {
|
|
1553
|
-
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y))
|
|
1554
|
-
continue;
|
|
1555
|
-
scatterPoints.push({
|
|
1556
|
-
cx: xOf(pt.x),
|
|
1557
|
-
cy: yOf(pt.y),
|
|
1558
|
-
r: rOf(pt.r),
|
|
1559
|
-
color: s.color,
|
|
1560
|
-
label: pt.label ?? '',
|
|
1561
|
-
series: s.label,
|
|
1562
|
-
x: pt.x,
|
|
1563
|
-
y: pt.y,
|
|
1564
|
-
});
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
const referenceLines = (spec.referenceLines ?? []).map((ref) => ({
|
|
1568
|
-
y: yOf(ref.value),
|
|
1569
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1570
|
-
color: ref.color ?? '#ef4444',
|
|
1571
|
-
dashed: ref.dashed !== false,
|
|
1572
|
-
}));
|
|
1573
|
-
return {
|
|
1574
|
-
...empty,
|
|
1575
|
-
plot,
|
|
1576
|
-
scatterPoints,
|
|
1577
|
-
referenceLines,
|
|
1578
|
-
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
|
|
1579
|
-
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
1580
|
-
};
|
|
1581
|
-
}
|
|
165
|
+
const ctx = { spec, theme, width, height, palette, stacked, series, legend, empty, frame, axes };
|
|
166
|
+
if (spec.type === 'waterfall')
|
|
167
|
+
return layoutWaterfall(ctx);
|
|
168
|
+
if (spec.type === 'funnel')
|
|
169
|
+
return layoutFunnel(ctx);
|
|
170
|
+
if (spec.type === 'radar')
|
|
171
|
+
return layoutRadar(ctx);
|
|
172
|
+
if (spec.type === 'calendar')
|
|
173
|
+
return layoutCalendar(ctx);
|
|
174
|
+
if (spec.type === 'gauge')
|
|
175
|
+
return layoutGauge(ctx);
|
|
176
|
+
if (spec.type === 'treemap')
|
|
177
|
+
return layoutTreemap(ctx);
|
|
178
|
+
if (spec.type === 'sankey')
|
|
179
|
+
return layoutSankey(ctx);
|
|
180
|
+
if (spec.type === 'heatmap')
|
|
181
|
+
return layoutHeatmap(ctx);
|
|
182
|
+
if (spec.type === 'pie')
|
|
183
|
+
return layoutPie(ctx);
|
|
184
|
+
if (spec.type === 'scatter')
|
|
185
|
+
return layoutScatter(ctx);
|
|
186
|
+
if (spec.type === 'sunburst')
|
|
187
|
+
return layoutSunburst(ctx);
|
|
188
|
+
if (spec.type === 'radial-bar')
|
|
189
|
+
return layoutRadialBar(ctx);
|
|
190
|
+
if (spec.type === 'radial-column' || spec.type === 'nightingale')
|
|
191
|
+
return layoutRadialColumn(ctx);
|
|
192
|
+
if (spec.type === 'chord')
|
|
193
|
+
return layoutChord(ctx);
|
|
194
|
+
if (spec.type === 'bullet')
|
|
195
|
+
return layoutBullet(ctx);
|
|
1582
196
|
// ---- Horizontal bars ----------------------------------------------------
|
|
1583
197
|
// Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
1584
|
-
const horizontal = spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar');
|
|
1585
|
-
if (horizontal)
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
const padR = 16;
|
|
1589
|
-
const padT = 8;
|
|
1590
|
-
const padB = 26 + (spec.xAxisTitle ? 16 : 0);
|
|
1591
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1592
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1593
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1594
|
-
const refs = (spec.referenceLines ?? []).map((r) => r.value);
|
|
1595
|
-
const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs);
|
|
1596
|
-
const xOf = (v) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW);
|
|
1597
|
-
const n = spec.categories.length;
|
|
1598
|
-
const slot = plotH / Math.max(1, n);
|
|
1599
|
-
const groupPad = slot * 0.2;
|
|
1600
|
-
const inner = slot - groupPad;
|
|
1601
|
-
const bandTop = (i) => padT + slot * i + groupPad / 2;
|
|
1602
|
-
const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max));
|
|
1603
|
-
const bars = [];
|
|
1604
|
-
if (stacked) {
|
|
1605
|
-
const totals = spec.stacked100
|
|
1606
|
-
? spec.categories.map((_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1)
|
|
1607
|
-
: null;
|
|
1608
|
-
const pos = new Array(n).fill(0);
|
|
1609
|
-
const neg = new Array(n).fill(0);
|
|
1610
|
-
for (const s of series) {
|
|
1611
|
-
s.values.forEach((v, i) => {
|
|
1612
|
-
if (!Number.isFinite(v))
|
|
1613
|
-
return;
|
|
1614
|
-
const vp = totals ? (v / totals[i]) * 100 : v;
|
|
1615
|
-
let xL;
|
|
1616
|
-
let xR;
|
|
1617
|
-
if (vp >= 0) {
|
|
1618
|
-
xL = xOf(pos[i]);
|
|
1619
|
-
xR = xOf(pos[i] + vp);
|
|
1620
|
-
pos[i] += vp;
|
|
1621
|
-
}
|
|
1622
|
-
else {
|
|
1623
|
-
xL = xOf(neg[i] + vp);
|
|
1624
|
-
xR = xOf(neg[i]);
|
|
1625
|
-
neg[i] += vp;
|
|
1626
|
-
}
|
|
1627
|
-
bars.push({
|
|
1628
|
-
x: Math.min(xL, xR),
|
|
1629
|
-
y: round(bandTop(i)),
|
|
1630
|
-
w: round(Math.abs(xR - xL)),
|
|
1631
|
-
h: round(Math.max(1, inner)),
|
|
1632
|
-
color: s.color,
|
|
1633
|
-
label: spec.categories[i] ?? String(i),
|
|
1634
|
-
series: s.label,
|
|
1635
|
-
value: v,
|
|
1636
|
-
});
|
|
1637
|
-
});
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
else {
|
|
1641
|
-
const barH = inner / series.length;
|
|
1642
|
-
series.forEach((s, bi) => {
|
|
1643
|
-
s.values.forEach((v, i) => {
|
|
1644
|
-
if (!Number.isFinite(v))
|
|
1645
|
-
return;
|
|
1646
|
-
const xV = xOf(v);
|
|
1647
|
-
bars.push({
|
|
1648
|
-
x: Math.min(xV, xBase),
|
|
1649
|
-
y: round(bandTop(i) + barH * bi),
|
|
1650
|
-
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
1651
|
-
h: round(Math.max(1, barH - 1)),
|
|
1652
|
-
color: s.color,
|
|
1653
|
-
label: spec.categories[i] ?? String(i),
|
|
1654
|
-
series: s.label,
|
|
1655
|
-
value: v,
|
|
1656
|
-
});
|
|
1657
|
-
});
|
|
1658
|
-
});
|
|
1659
|
-
}
|
|
1660
|
-
const valueTicks = dom.ticks.map((value) => ({
|
|
1661
|
-
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
1662
|
-
x: xOf(value),
|
|
1663
|
-
}));
|
|
1664
|
-
const catTicks = spec.categories.map((label, i) => ({
|
|
1665
|
-
value: i,
|
|
1666
|
-
y: round(bandTop(i) + inner / 2),
|
|
1667
|
-
label,
|
|
1668
|
-
}));
|
|
1669
|
-
const referenceLinesV = (spec.referenceLines ?? []).map((ref) => ({
|
|
1670
|
-
x: xOf(ref.value),
|
|
1671
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1672
|
-
color: ref.color ?? '#ef4444',
|
|
1673
|
-
dashed: ref.dashed !== false,
|
|
1674
|
-
}));
|
|
1675
|
-
return {
|
|
1676
|
-
...empty,
|
|
1677
|
-
plot,
|
|
1678
|
-
bars,
|
|
1679
|
-
orientation: 'horizontal',
|
|
1680
|
-
valueTicks,
|
|
1681
|
-
catTicks,
|
|
1682
|
-
referenceLinesV,
|
|
1683
|
-
xLabelRotated: false,
|
|
1684
|
-
};
|
|
1685
|
-
}
|
|
1686
|
-
// ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
|
|
1687
|
-
const leftSeries = series.filter((s) => s.axis === 'left');
|
|
1688
|
-
const rightSeries = series.filter((s) => s.axis === 'right');
|
|
1689
|
-
const hasRightAxis = rightSeries.length > 0;
|
|
1690
|
-
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0);
|
|
1691
|
-
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9;
|
|
1692
|
-
// Grouped (nested) category axis: valid only when the spans cover every leaf.
|
|
1693
|
-
const validGroups = spec.categoryGroups &&
|
|
1694
|
-
spec.categoryGroups.length > 0 &&
|
|
1695
|
-
spec.xType !== 'time' &&
|
|
1696
|
-
spec.orientation !== 'horizontal' &&
|
|
1697
|
-
spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
|
|
1698
|
-
? spec.categoryGroups
|
|
1699
|
-
: null;
|
|
1700
|
-
const groupTierH = validGroups ? 18 : 0;
|
|
1701
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0);
|
|
1702
|
-
const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0);
|
|
1703
|
-
const padT = 10;
|
|
1704
|
-
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH;
|
|
1705
|
-
const plotW = Math.max(1, width - padL - padR);
|
|
1706
|
-
const plotH = Math.max(1, height - padT - padB);
|
|
1707
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1708
|
-
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value);
|
|
1709
|
-
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value);
|
|
1710
|
-
const leftLog = spec.yScale === 'log';
|
|
1711
|
-
const rightLog = spec.y2Scale === 'log';
|
|
1712
|
-
const leftDom = spec.stacked100
|
|
1713
|
-
? niceScale(0, 100)
|
|
1714
|
-
: axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog);
|
|
1715
|
-
const rightDom = hasRightAxis
|
|
1716
|
-
? spec.stacked100
|
|
1717
|
-
? niceScale(0, 100)
|
|
1718
|
-
: axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
|
|
1719
|
-
: null;
|
|
1720
|
-
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
1721
|
-
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
1722
|
-
const yOf = (dom, v, isLog = false) => {
|
|
1723
|
-
const t = project(v, dom.min, dom.max, isLog);
|
|
1724
|
-
if (t === null)
|
|
1725
|
-
return NaN;
|
|
1726
|
-
return round(padT + plotH - t * plotH);
|
|
1727
|
-
};
|
|
1728
|
-
const yLeft = (v) => yOf(leftDom, v, leftLog);
|
|
1729
|
-
const yRight = (v) => yOf(rightDom ?? leftDom, v, rightLog);
|
|
1730
|
-
const domOf = (s) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom);
|
|
1731
|
-
const isLogOf = (s) => (s.axis === 'right' ? rightLog : leftLog);
|
|
1732
|
-
const n = spec.categories.length;
|
|
1733
|
-
const slot = plotW / Math.max(1, n);
|
|
1734
|
-
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
1735
|
-
// a category axis is uniform. (Bars stay uniform either way.)
|
|
1736
|
-
// Two date modes, and the difference is only where the marks go: `'time'`
|
|
1737
|
-
// positions by the timestamp, `'ordinal-time'` positions by the index and
|
|
1738
|
-
// uses the dates for labels alone. Both parse; only one scales.
|
|
1739
|
-
const isDateAxis = spec.xType === 'time' || spec.xType === 'ordinal-time';
|
|
1740
|
-
const timeVals = isDateAxis ? spec.categories.map((c) => Date.parse(c)) : null;
|
|
1741
|
-
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t));
|
|
1742
|
-
const timeScaled = timeOk && spec.xType === 'time';
|
|
1743
|
-
const tMin = timeOk ? Math.min(...timeVals.filter(Number.isFinite)) : 0;
|
|
1744
|
-
const tSpan = timeOk ? Math.max(...timeVals.filter(Number.isFinite)) - tMin || 1 : 1;
|
|
1745
|
-
const xCenter = (i) => timeScaled && Number.isFinite(timeVals[i])
|
|
1746
|
-
? round(padL + ((timeVals[i] - tMin) / tSpan) * plotW)
|
|
1747
|
-
: round(padL + slot * i + slot / 2);
|
|
1748
|
-
const xTicks = timeScaled
|
|
1749
|
-
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
1750
|
-
label: fmtDate(t, tSpan),
|
|
1751
|
-
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
1752
|
-
}))
|
|
1753
|
-
: timeOk
|
|
1754
|
-
? // Ordinal: ticks land on points that exist, labelled from their dates.
|
|
1755
|
-
ordinalDateTicks(timeVals).map((i) => ({
|
|
1756
|
-
label: fmtDate(timeVals[i], tSpan),
|
|
1757
|
-
x: xCenter(i),
|
|
1758
|
-
}))
|
|
1759
|
-
: thinCategoryTicks(spec.categories, xCenter, slot, xLabelRotated);
|
|
1760
|
-
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1761
|
-
const categoryGroupTicks = [];
|
|
1762
|
-
if (validGroups && !timeOk) {
|
|
1763
|
-
let start = 0;
|
|
1764
|
-
for (const g of validGroups) {
|
|
1765
|
-
const x0 = round(padL + slot * start);
|
|
1766
|
-
const x1 = round(padL + slot * (start + g.span));
|
|
1767
|
-
categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) });
|
|
1768
|
-
start += g.span;
|
|
1769
|
-
}
|
|
1770
|
-
}
|
|
1771
|
-
const barSeries = series.filter((s) => s.kind === 'bar');
|
|
1772
|
-
const bars = [];
|
|
1773
|
-
if (barSeries.length) {
|
|
1774
|
-
const groupPad = slot * 0.2;
|
|
1775
|
-
if (stacked) {
|
|
1776
|
-
const inner = slot - groupPad;
|
|
1777
|
-
const x0 = (i) => padL + slot * i + groupPad / 2;
|
|
1778
|
-
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
1779
|
-
for (const axis of ['left', 'right']) {
|
|
1780
|
-
const axisBars = barSeries.filter((s) => s.axis === axis);
|
|
1781
|
-
if (!axisBars.length)
|
|
1782
|
-
continue;
|
|
1783
|
-
const yA = axis === 'right' ? yRight : yLeft;
|
|
1784
|
-
// 100% mode normalizes each category to its absolute total.
|
|
1785
|
-
const totals = spec.stacked100
|
|
1786
|
-
? spec.categories.map((_, i) => axisBars.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1)
|
|
1787
|
-
: null;
|
|
1788
|
-
const pos = new Array(n).fill(0);
|
|
1789
|
-
const neg = new Array(n).fill(0);
|
|
1790
|
-
for (const s of axisBars) {
|
|
1791
|
-
s.values.forEach((v, i) => {
|
|
1792
|
-
if (!Number.isFinite(v))
|
|
1793
|
-
return;
|
|
1794
|
-
const vp = totals ? (v / totals[i]) * 100 : v;
|
|
1795
|
-
let yTop;
|
|
1796
|
-
let yBot;
|
|
1797
|
-
if (vp >= 0) {
|
|
1798
|
-
yTop = yA(pos[i] + vp);
|
|
1799
|
-
yBot = yA(pos[i]);
|
|
1800
|
-
pos[i] += vp;
|
|
1801
|
-
}
|
|
1802
|
-
else {
|
|
1803
|
-
yTop = yA(neg[i]);
|
|
1804
|
-
yBot = yA(neg[i] + vp);
|
|
1805
|
-
neg[i] += vp;
|
|
1806
|
-
}
|
|
1807
|
-
bars.push({
|
|
1808
|
-
x: round(x0(i)),
|
|
1809
|
-
y: Math.min(yTop, yBot),
|
|
1810
|
-
w: round(Math.max(1, inner)),
|
|
1811
|
-
h: round(Math.abs(yBot - yTop)),
|
|
1812
|
-
color: s.color,
|
|
1813
|
-
label: spec.categories[i] ?? String(i),
|
|
1814
|
-
series: s.label,
|
|
1815
|
-
value: v,
|
|
1816
|
-
});
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
else {
|
|
1822
|
-
const inner = slot - groupPad;
|
|
1823
|
-
const barW = inner / barSeries.length;
|
|
1824
|
-
barSeries.forEach((s, bi) => {
|
|
1825
|
-
const dom = domOf(s);
|
|
1826
|
-
const log = isLogOf(s);
|
|
1827
|
-
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
1828
|
-
// than from 0, since 0 is invalid in log space.
|
|
1829
|
-
const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log);
|
|
1830
|
-
s.values.forEach((v, i) => {
|
|
1831
|
-
if (!Number.isFinite(v))
|
|
1832
|
-
return;
|
|
1833
|
-
if (log && v <= 0)
|
|
1834
|
-
return;
|
|
1835
|
-
const x = padL + slot * i + groupPad / 2 + barW * bi;
|
|
1836
|
-
const yV = yOf(dom, v, log);
|
|
1837
|
-
bars.push({
|
|
1838
|
-
x: round(x),
|
|
1839
|
-
y: Math.min(yV, base),
|
|
1840
|
-
w: round(Math.max(1, barW - 1)),
|
|
1841
|
-
h: round(Math.max(1, Math.abs(yV - base))),
|
|
1842
|
-
color: s.color,
|
|
1843
|
-
label: spec.categories[i] ?? String(i),
|
|
1844
|
-
series: s.label,
|
|
1845
|
-
value: v,
|
|
1846
|
-
});
|
|
1847
|
-
});
|
|
1848
|
-
});
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
// Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
|
|
1852
|
-
// the renderer paints them, so there is no second geometry pass.
|
|
1853
|
-
const candleSeries = series.filter((s) => s.kind === 'candle');
|
|
1854
|
-
const candles = [];
|
|
1855
|
-
if (candleSeries.length) {
|
|
1856
|
-
const upColor = spec.candleColors?.up ?? '#16a34a';
|
|
1857
|
-
const downColor = spec.candleColors?.down ?? '#ef4444';
|
|
1858
|
-
// Share the slot when two instruments are charted together, the same way
|
|
1859
|
-
// grouped bars do.
|
|
1860
|
-
const bodyW = Math.max(1, (slot * 0.7) / candleSeries.length);
|
|
1861
|
-
candleSeries.forEach((s, si) => {
|
|
1862
|
-
const dom = domOf(s);
|
|
1863
|
-
const log = isLogOf(s);
|
|
1864
|
-
(s.ohlc ?? []).forEach((k, i) => {
|
|
1865
|
-
if (!k)
|
|
1866
|
-
return;
|
|
1867
|
-
if (![k.o, k.h, k.l, k.c].every(Number.isFinite))
|
|
1868
|
-
return;
|
|
1869
|
-
// A log price axis is genuinely used for long histories, and a
|
|
1870
|
-
// non-positive price has no place on one.
|
|
1871
|
-
if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0))
|
|
1872
|
-
return;
|
|
1873
|
-
const centre = padL + slot * i + slot / 2;
|
|
1874
|
-
const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si;
|
|
1875
|
-
const yOpen = yOf(dom, k.o, log);
|
|
1876
|
-
const yClose = yOf(dom, k.c, log);
|
|
1877
|
-
const up = k.c >= k.o;
|
|
1878
|
-
candles.push({
|
|
1879
|
-
x: round(x),
|
|
1880
|
-
w: round(bodyW),
|
|
1881
|
-
xCenter: round(x + bodyW / 2),
|
|
1882
|
-
yOpen,
|
|
1883
|
-
yClose,
|
|
1884
|
-
yHigh: yOf(dom, k.h, log),
|
|
1885
|
-
yLow: yOf(dom, k.l, log),
|
|
1886
|
-
bodyY: Math.min(yOpen, yClose),
|
|
1887
|
-
// A doji closes where it opened; keep it visible as a 1px line
|
|
1888
|
-
// rather than a zero-height rect that paints nothing.
|
|
1889
|
-
bodyH: Math.max(1, Math.abs(yClose - yOpen)),
|
|
1890
|
-
up,
|
|
1891
|
-
color: up ? upColor : downColor,
|
|
1892
|
-
label: spec.categories[i] ?? String(i),
|
|
1893
|
-
series: s.label,
|
|
1894
|
-
o: k.o,
|
|
1895
|
-
h: k.h,
|
|
1896
|
-
l: k.l,
|
|
1897
|
-
c: k.c,
|
|
1898
|
-
});
|
|
1899
|
-
});
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
// Box plots. Same slot-sharing as grouped bars and candles, so several
|
|
1903
|
-
// samples can sit side by side under one category.
|
|
1904
|
-
const boxSeries = series.filter((s) => s.kind === 'box');
|
|
1905
|
-
const boxes = [];
|
|
1906
|
-
if (boxSeries.length) {
|
|
1907
|
-
const boxW = Math.max(1, (slot * 0.6) / boxSeries.length);
|
|
1908
|
-
boxSeries.forEach((s, si) => {
|
|
1909
|
-
const dom = domOf(s);
|
|
1910
|
-
const log = isLogOf(s);
|
|
1911
|
-
(s.boxes ?? []).forEach((b, i) => {
|
|
1912
|
-
if (!b)
|
|
1913
|
-
return;
|
|
1914
|
-
if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite))
|
|
1915
|
-
return;
|
|
1916
|
-
if (log && b.min <= 0)
|
|
1917
|
-
return;
|
|
1918
|
-
const centre = padL + slot * i + slot / 2;
|
|
1919
|
-
const x = centre - (boxW * boxSeries.length) / 2 + boxW * si;
|
|
1920
|
-
const yQ1 = yOf(dom, b.q1, log);
|
|
1921
|
-
const yQ3 = yOf(dom, b.q3, log);
|
|
1922
|
-
boxes.push({
|
|
1923
|
-
x: round(x),
|
|
1924
|
-
w: round(boxW),
|
|
1925
|
-
xCenter: round(x + boxW / 2),
|
|
1926
|
-
yMin: yOf(dom, b.min, log),
|
|
1927
|
-
yQ1,
|
|
1928
|
-
yMedian: yOf(dom, b.median, log),
|
|
1929
|
-
yQ3,
|
|
1930
|
-
yMax: yOf(dom, b.max, log),
|
|
1931
|
-
boxY: Math.min(yQ1, yQ3),
|
|
1932
|
-
// A sample with no spread would otherwise paint nothing at all.
|
|
1933
|
-
boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
|
|
1934
|
-
outliers: (b.outliers ?? [])
|
|
1935
|
-
.filter((o) => Number.isFinite(o) && (!log || o > 0))
|
|
1936
|
-
.map((o) => ({ y: yOf(dom, o, log), value: o })),
|
|
1937
|
-
color: s.color,
|
|
1938
|
-
label: spec.categories[i] ?? String(i),
|
|
1939
|
-
series: s.label,
|
|
1940
|
-
min: b.min,
|
|
1941
|
-
q1: b.q1,
|
|
1942
|
-
median: b.median,
|
|
1943
|
-
q3: b.q3,
|
|
1944
|
-
max: b.max,
|
|
1945
|
-
});
|
|
1946
|
-
});
|
|
1947
|
-
});
|
|
1948
|
-
}
|
|
1949
|
-
// Error bars. Not a mark of their own: they annotate whatever the series
|
|
1950
|
-
// already draws, so this runs over every series carrying `errors` regardless
|
|
1951
|
-
// of kind, and the geometry sits in its own array so no existing loop changes.
|
|
1952
|
-
const errorBars = [];
|
|
1953
|
-
for (const s of series) {
|
|
1954
|
-
if (!s.errors)
|
|
1955
|
-
continue;
|
|
1956
|
-
const dom = domOf(s);
|
|
1957
|
-
const log = isLogOf(s);
|
|
1958
|
-
s.errors.forEach((e, i) => {
|
|
1959
|
-
const v = s.values[i];
|
|
1960
|
-
if (!Number.isFinite(v))
|
|
1961
|
-
return;
|
|
1962
|
-
const span = errorSpan(e, v);
|
|
1963
|
-
if (!span)
|
|
1964
|
-
return;
|
|
1965
|
-
if (log && span.lo <= 0)
|
|
1966
|
-
return;
|
|
1967
|
-
errorBars.push({
|
|
1968
|
-
xCenter: round(padL + slot * i + slot / 2),
|
|
1969
|
-
yLo: yOf(dom, span.lo, log),
|
|
1970
|
-
yHi: yOf(dom, span.hi, log),
|
|
1971
|
-
cap: round(Math.min(6, slot * 0.15)),
|
|
1972
|
-
color: s.color,
|
|
1973
|
-
label: spec.categories[i] ?? String(i),
|
|
1974
|
-
series: s.label,
|
|
1975
|
-
lo: span.lo,
|
|
1976
|
-
hi: span.hi,
|
|
1977
|
-
});
|
|
1978
|
-
});
|
|
1979
|
-
}
|
|
1980
|
-
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
1981
|
-
const lines = [];
|
|
1982
|
-
const areaCum = {
|
|
1983
|
-
left: new Array(n).fill(0),
|
|
1984
|
-
right: new Array(n).fill(0),
|
|
1985
|
-
};
|
|
1986
|
-
// 100% mode: per-axis per-category totals to normalize stacked areas to 100.
|
|
1987
|
-
const areaTotals = { left: null, right: null };
|
|
1988
|
-
if (spec.stacked100) {
|
|
1989
|
-
for (const axis of ['left', 'right']) {
|
|
1990
|
-
const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis);
|
|
1991
|
-
if (areaSeries.length) {
|
|
1992
|
-
areaTotals[axis] = spec.categories.map((_, i) => areaSeries.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1);
|
|
1993
|
-
}
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
for (const s of series) {
|
|
1997
|
-
// Bars, candles and boxes draw their own marks. Candles and boxes
|
|
1998
|
-
// especially: `values` holds their closes / medians so that tooltips, CSV
|
|
1999
|
-
// and overlays work, and without this guard that same array was ALSO drawn
|
|
2000
|
-
// as a line, laying a dotted close-line straight over every candle. Boxes
|
|
2001
|
-
// would do exactly the same thing through the median.
|
|
2002
|
-
if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box')
|
|
2003
|
-
continue;
|
|
2004
|
-
const dom = domOf(s);
|
|
2005
|
-
const log = isLogOf(s);
|
|
2006
|
-
const yA = (v) => yOf(dom, v, log);
|
|
2007
|
-
const isStackedArea = stacked && s.kind === 'area';
|
|
2008
|
-
const px = (i) => xCenter(i);
|
|
2009
|
-
let pts;
|
|
2010
|
-
let baselinePts = null;
|
|
2011
|
-
if (isStackedArea) {
|
|
2012
|
-
// Stacked areas treat a gap as 0 so the stack stays continuous.
|
|
2013
|
-
const cum = areaCum[s.axis];
|
|
2014
|
-
const prev = cum.slice();
|
|
2015
|
-
const totals = areaTotals[s.axis];
|
|
2016
|
-
pts = s.values.map((v, i) => {
|
|
2017
|
-
const vv = Number.isFinite(v) ? v : 0;
|
|
2018
|
-
// 100% mode positions by share of the category total; value stays original.
|
|
2019
|
-
const norm = totals ? (vv / totals[i]) * 100 : vv;
|
|
2020
|
-
const c = (cum[i] ?? 0) + norm;
|
|
2021
|
-
cum[i] = c;
|
|
2022
|
-
return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) };
|
|
2023
|
-
});
|
|
2024
|
-
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }));
|
|
2025
|
-
}
|
|
2026
|
-
else {
|
|
2027
|
-
pts = s.values.map((v, i) => {
|
|
2028
|
-
const ok = Number.isFinite(v);
|
|
2029
|
-
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok };
|
|
2030
|
-
});
|
|
2031
|
-
}
|
|
2032
|
-
// Build the line - smoothed via monotone cubic when requested, else
|
|
2033
|
-
// straight polylines. Either way, gaps break the path cleanly.
|
|
2034
|
-
const smooth = !!s.smooth;
|
|
2035
|
-
const path = buildLinePath(pts, smooth);
|
|
2036
|
-
let areaPath = '';
|
|
2037
|
-
if (s.kind === 'area' && pts.length) {
|
|
2038
|
-
if (baselinePts) {
|
|
2039
|
-
const top = smooth
|
|
2040
|
-
? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
|
|
2041
|
-
: pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
2042
|
-
const back = smooth
|
|
2043
|
-
? `L${baselinePts[baselinePts.length - 1].x},${baselinePts[baselinePts.length - 1].y} ` +
|
|
2044
|
-
monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2045
|
-
: baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
|
|
2046
|
-
areaPath = `${top} ${back} Z`;
|
|
2047
|
-
}
|
|
2048
|
-
else {
|
|
2049
|
-
// One filled polygon per contiguous run of defined points.
|
|
2050
|
-
const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)));
|
|
2051
|
-
const runs = [];
|
|
2052
|
-
let cur = [];
|
|
2053
|
-
for (const p of pts) {
|
|
2054
|
-
if (p.defined)
|
|
2055
|
-
cur.push(p);
|
|
2056
|
-
else if (cur.length) {
|
|
2057
|
-
runs.push(cur);
|
|
2058
|
-
cur = [];
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
if (cur.length)
|
|
2062
|
-
runs.push(cur);
|
|
2063
|
-
areaPath = runs
|
|
2064
|
-
.map((run) => {
|
|
2065
|
-
const top = smooth
|
|
2066
|
-
? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
|
|
2067
|
-
: run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
2068
|
-
return `${top} L${run[run.length - 1].x},${baseY} L${run[0].x},${baseY} Z`;
|
|
2069
|
-
})
|
|
2070
|
-
.join(' ');
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
2074
|
-
// Both arrays must be present and aligned to the value array.
|
|
2075
|
-
let bandPath = '';
|
|
2076
|
-
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
2077
|
-
const upperPts = [];
|
|
2078
|
-
const lowerPts = [];
|
|
2079
|
-
for (let i = 0; i < s.values.length; i += 1) {
|
|
2080
|
-
const u = s.upperValues[i];
|
|
2081
|
-
const lo = s.lowerValues[i];
|
|
2082
|
-
if (!Number.isFinite(u) || !Number.isFinite(lo))
|
|
2083
|
-
continue;
|
|
2084
|
-
if (log && (u <= 0 || lo <= 0))
|
|
2085
|
-
continue;
|
|
2086
|
-
upperPts.push({ x: px(i), y: yA(u) });
|
|
2087
|
-
lowerPts.push({ x: px(i), y: yA(lo) });
|
|
2088
|
-
}
|
|
2089
|
-
if (upperPts.length >= 2) {
|
|
2090
|
-
const top = smooth
|
|
2091
|
-
? monotoneCubicPath(upperPts)
|
|
2092
|
-
: upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
2093
|
-
const back = smooth
|
|
2094
|
-
? `L${lowerPts[lowerPts.length - 1].x},${lowerPts[lowerPts.length - 1].y} ` +
|
|
2095
|
-
monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2096
|
-
: lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
|
|
2097
|
-
bandPath = `${top} ${back} Z`;
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath });
|
|
2101
|
-
}
|
|
2102
|
-
// Under `stacked100` the axis is a share of the total, not the measure, so
|
|
2103
|
-
// it is labelled as a percentage whatever `valueFormat` says. Formatting it
|
|
2104
|
-
// as currency gives an axis reading "$0 .. $100" for what are percentages -
|
|
2105
|
-
// which is what it did, unnoticed, while `stacked100` was reachable only
|
|
2106
|
-
// from config.
|
|
2107
|
-
const tickFor = (dom, log) => dom.ticks.map((value) => ({
|
|
2108
|
-
value,
|
|
2109
|
-
y: yOf(dom, value, log),
|
|
2110
|
-
label: spec.stacked100 ? `${round(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
2111
|
-
}));
|
|
2112
|
-
const referenceLines = (spec.referenceLines ?? []).map((ref) => {
|
|
2113
|
-
const onRight = ref.axis === 'right';
|
|
2114
|
-
const dom = onRight ? (rightDom ?? leftDom) : leftDom;
|
|
2115
|
-
const log = onRight ? rightLog : leftLog;
|
|
2116
|
-
return {
|
|
2117
|
-
y: yOf(dom, ref.value, log),
|
|
2118
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
2119
|
-
color: ref.color ?? '#ef4444',
|
|
2120
|
-
dashed: ref.dashed !== false,
|
|
2121
|
-
};
|
|
2122
|
-
});
|
|
2123
|
-
// ---- Overlays: trendline / moving average ------------------------
|
|
2124
|
-
// For every series with an `overlay`, compute the smoothed values and
|
|
2125
|
-
// render as a dashed line in the source series' color (or overlayColor).
|
|
2126
|
-
const overlays = [];
|
|
2127
|
-
for (const s of series) {
|
|
2128
|
-
if (!s.overlay)
|
|
2129
|
-
continue;
|
|
2130
|
-
const dom = domOf(s);
|
|
2131
|
-
const log = isLogOf(s);
|
|
2132
|
-
const overlayVals = computeOverlay(s.values, s.overlay);
|
|
2133
|
-
const color = s.overlayColor ?? s.color;
|
|
2134
|
-
const pts = overlayVals.map((v, i) => {
|
|
2135
|
-
const ok = Number.isFinite(v) && (!log || v > 0);
|
|
2136
|
-
return {
|
|
2137
|
-
x: xCenter(i),
|
|
2138
|
-
y: ok ? yOf(dom, v, log) : NaN,
|
|
2139
|
-
label: spec.categories[i] ?? String(i),
|
|
2140
|
-
value: v,
|
|
2141
|
-
defined: ok,
|
|
2142
|
-
};
|
|
2143
|
-
});
|
|
2144
|
-
const path = buildLinePath(pts, !!s.smooth);
|
|
2145
|
-
overlays.push({
|
|
2146
|
-
path,
|
|
2147
|
-
areaPath: '',
|
|
2148
|
-
color,
|
|
2149
|
-
label: `${s.label} (${s.overlay})`,
|
|
2150
|
-
points: pts,
|
|
2151
|
-
});
|
|
2152
|
-
}
|
|
2153
|
-
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
2154
|
-
const annotations = [];
|
|
2155
|
-
for (const a of (spec.annotations ?? [])) {
|
|
2156
|
-
let ax = null;
|
|
2157
|
-
let ay = null;
|
|
2158
|
-
if ('category' in a.at) {
|
|
2159
|
-
const ci = spec.categories.indexOf(a.at.category);
|
|
2160
|
-
if (ci < 0)
|
|
2161
|
-
continue;
|
|
2162
|
-
ax = xCenter(ci);
|
|
2163
|
-
// Anchor to the named series' value at that category, else just
|
|
2164
|
-
// mid-plot. Picks the first matching series if `series` is set.
|
|
2165
|
-
const seriesName = a.at.series;
|
|
2166
|
-
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0];
|
|
2167
|
-
if (s) {
|
|
2168
|
-
const v = s.values[ci];
|
|
2169
|
-
if (Number.isFinite(v))
|
|
2170
|
-
ay = yOf(domOf(s), v, isLogOf(s));
|
|
2171
|
-
}
|
|
2172
|
-
if (ay == null)
|
|
2173
|
-
ay = padT + plotH / 2;
|
|
2174
|
-
}
|
|
2175
|
-
else {
|
|
2176
|
-
// Raw x/y in data space (x ignored for category x-axis; takes the
|
|
2177
|
-
// mid-plot in that case). y projects through the left axis.
|
|
2178
|
-
ax = padL + plotW / 2;
|
|
2179
|
-
if (Number.isFinite(a.at.y))
|
|
2180
|
-
ay = yOf(leftDom, a.at.y, leftLog);
|
|
2181
|
-
else
|
|
2182
|
-
ay = padT + plotH / 2;
|
|
2183
|
-
}
|
|
2184
|
-
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
2185
|
-
annotations.push({
|
|
2186
|
-
x: ax,
|
|
2187
|
-
y: ay,
|
|
2188
|
-
label: a.label,
|
|
2189
|
-
color: a.color ?? '#0f172a',
|
|
2190
|
-
placement: a.placement ?? 'top',
|
|
2191
|
-
});
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
return {
|
|
2195
|
-
...empty,
|
|
2196
|
-
plot,
|
|
2197
|
-
// What a caller needs to put its own marks in this chart's coordinates.
|
|
2198
|
-
// Reported rather than recomputed, so a custom mark lands on exactly the
|
|
2199
|
-
// scale the built-in ones did - including the nice-scale rounding and the
|
|
2200
|
-
// "always include zero" rule, which are impossible to guess from outside.
|
|
2201
|
-
axes: {
|
|
2202
|
-
y: { min: leftDom.min, max: leftDom.max, log: leftLog },
|
|
2203
|
-
y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog } : null,
|
|
2204
|
-
slot,
|
|
2205
|
-
count: spec.categories.length,
|
|
2206
|
-
},
|
|
2207
|
-
bars,
|
|
2208
|
-
candles,
|
|
2209
|
-
boxes,
|
|
2210
|
-
errorBars,
|
|
2211
|
-
lines,
|
|
2212
|
-
yTicks: tickFor(leftDom, leftLog),
|
|
2213
|
-
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
2214
|
-
hasRightAxis,
|
|
2215
|
-
xTicks,
|
|
2216
|
-
categoryGroupTicks,
|
|
2217
|
-
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
2218
|
-
referenceLines,
|
|
2219
|
-
overlays,
|
|
2220
|
-
annotations,
|
|
2221
|
-
};
|
|
198
|
+
const horizontal = spec.orientation === 'horizontal' && spec.type !== 'histogram' && series.length > 0 && series.every((s) => s.kind === 'bar');
|
|
199
|
+
if (horizontal)
|
|
200
|
+
return layoutHorizontalBars(ctx);
|
|
201
|
+
return layoutCartesian(ctx);
|
|
2222
202
|
}
|
|
2223
203
|
/**
|
|
2224
204
|
* Narrow a spec to the category window `[lo, hi]`, keeping every
|
|
@@ -2239,31 +219,20 @@ export function buildChart(spec, theme = 'light') {
|
|
|
2239
219
|
export function sliceChartWindow(spec, lo, hi) {
|
|
2240
220
|
const from = Math.max(0, lo);
|
|
2241
221
|
const to = Math.min(spec.categories.length - 1, hi);
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
rowIds: cut(s.rowIds),
|
|
2257
|
-
upperValues: cut(s.upperValues),
|
|
2258
|
-
lowerValues: cut(s.lowerValues),
|
|
2259
|
-
ohlc: cut(s.ohlc),
|
|
2260
|
-
boxes: cut(s.boxes),
|
|
2261
|
-
errors: cut(s.errors),
|
|
2262
|
-
})),
|
|
2263
|
-
// Per-category, so it has to travel with the window or the waterfall's
|
|
2264
|
-
// running total resets on the wrong bars.
|
|
2265
|
-
waterfallTotals: cut(spec.waterfallTotals),
|
|
2266
|
-
};
|
|
222
|
+
if (to < from)
|
|
223
|
+
return { ...spec, categories: [], series: spec.series.map((s) => ({ ...s, values: [] })) };
|
|
224
|
+
// EVERY per-category array on a series has to be cut here, not just the
|
|
225
|
+
// ones that existed when this function was written. A missed one does not
|
|
226
|
+
// throw: the geometry keeps indexing the full-length array against the
|
|
227
|
+
// sliced categories, so marks land at the wrong x or off the plot entirely.
|
|
228
|
+
// `upperValues` / `lowerValues` were missed once and silently dropped the
|
|
229
|
+
// confidence band on zoom; `ohlc` was missed the same way. The list of those
|
|
230
|
+
// arrays now lives in one constant (PER_CATEGORY_SERIES_KEYS) shared with
|
|
231
|
+
// decimation, and `pickCategories` is the one function that applies it.
|
|
232
|
+
const indices = new Array(to - from + 1);
|
|
233
|
+
for (let i = from; i <= to; i += 1)
|
|
234
|
+
indices[i - from] = i;
|
|
235
|
+
return pickCategories(spec, indices);
|
|
2267
236
|
}
|
|
2268
237
|
/**
|
|
2269
238
|
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
@@ -2275,8 +244,13 @@ export function sliceChartWindow(spec, lo, hi) {
|
|
|
2275
244
|
*/
|
|
2276
245
|
export function rowsToChartSpec(rows, opts) {
|
|
2277
246
|
const reduce = opts.reduce ?? 'sum';
|
|
247
|
+
const wantSamples = reducerNeedsSamples(reduce);
|
|
2278
248
|
const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value];
|
|
2279
|
-
|
|
249
|
+
// The three original reducers run on a sum and a count; the rest keep every
|
|
250
|
+
// observation, which is what a median or a percentile needs.
|
|
251
|
+
const reduceCell = (cell) => wantSamples
|
|
252
|
+
? reduceValues(cell.samples, reduce)
|
|
253
|
+
: reduce === 'count' ? cell.count : reduce === 'avg' ? (cell.count ? cell.sum / cell.count : 0) : cell.sum;
|
|
2280
254
|
const categories = [];
|
|
2281
255
|
const catIndex = new Map();
|
|
2282
256
|
const ensureCat = (key) => {
|
|
@@ -2297,50 +271,51 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
2297
271
|
}
|
|
2298
272
|
return arr;
|
|
2299
273
|
};
|
|
274
|
+
const note = (cell, num, rowId) => {
|
|
275
|
+
cell.sum += num;
|
|
276
|
+
cell.count += 1;
|
|
277
|
+
if (wantSamples)
|
|
278
|
+
cell.samples.push(num);
|
|
279
|
+
if (rowId !== undefined)
|
|
280
|
+
cell.rowIds.push(rowId);
|
|
281
|
+
};
|
|
282
|
+
const fresh = () => ({ sum: 0, count: 0, rowIds: [], samples: [] });
|
|
2300
283
|
const trackIds = opts.idField !== undefined;
|
|
2301
284
|
for (const row of rows) {
|
|
2302
|
-
const
|
|
285
|
+
const raw = row[opts.category];
|
|
286
|
+
const cat = opts.bucket ? bucketStart(raw, opts.bucket) : String(raw ?? '');
|
|
287
|
+
if (opts.bucket && !cat)
|
|
288
|
+
continue;
|
|
2303
289
|
const ci = ensureCat(cat);
|
|
2304
290
|
const rowId = trackIds ? row[opts.idField] : undefined;
|
|
2305
291
|
if (opts.series) {
|
|
2306
292
|
const sName = String(row[opts.series] ?? '');
|
|
2307
293
|
const arr = ensureSeries(sName);
|
|
2308
294
|
const num = Number(row[valueFields[0]]);
|
|
2309
|
-
const cell = (arr[ci] ?? (arr[ci] =
|
|
2310
|
-
if (Number.isFinite(num))
|
|
2311
|
-
cell
|
|
2312
|
-
cell.count += 1;
|
|
2313
|
-
if (rowId !== undefined)
|
|
2314
|
-
cell.rowIds.push(rowId);
|
|
2315
|
-
}
|
|
295
|
+
const cell = (arr[ci] ?? (arr[ci] = fresh()));
|
|
296
|
+
if (Number.isFinite(num))
|
|
297
|
+
note(cell, num, rowId);
|
|
2316
298
|
}
|
|
2317
299
|
else {
|
|
2318
300
|
for (const vf of valueFields) {
|
|
2319
301
|
const arr = ensureSeries(vf);
|
|
2320
302
|
const num = Number(row[vf]);
|
|
2321
|
-
const cell = (arr[ci] ?? (arr[ci] =
|
|
2322
|
-
if (Number.isFinite(num))
|
|
2323
|
-
cell
|
|
2324
|
-
cell.count += 1;
|
|
2325
|
-
if (rowId !== undefined)
|
|
2326
|
-
cell.rowIds.push(rowId);
|
|
2327
|
-
}
|
|
303
|
+
const cell = (arr[ci] ?? (arr[ci] = fresh()));
|
|
304
|
+
if (Number.isFinite(num))
|
|
305
|
+
note(cell, num, rowId);
|
|
2328
306
|
}
|
|
2329
307
|
}
|
|
2330
308
|
}
|
|
2331
309
|
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
2332
310
|
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
2333
|
-
values: categories.map((_, i) =>
|
|
2334
|
-
const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] };
|
|
2335
|
-
return reduceCell(cell.sum, cell.count);
|
|
2336
|
-
}),
|
|
311
|
+
values: categories.map((_, i) => reduceCell(arr[i] ?? fresh())),
|
|
2337
312
|
rowIds: trackIds
|
|
2338
313
|
? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
|
|
2339
314
|
: undefined,
|
|
2340
315
|
}));
|
|
2341
316
|
// ---- Sort + top-N -----------------------------------------------------
|
|
2342
317
|
const totals = categories.map((_, i) => entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]) ? e.values[i] : 0), 0));
|
|
2343
|
-
const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none');
|
|
318
|
+
const sort = opts.sort ?? (opts.topN ? 'value-desc' : opts.bucket ? 'category' : 'none');
|
|
2344
319
|
const order = categories.map((_, i) => i);
|
|
2345
320
|
if (sort === 'value-desc')
|
|
2346
321
|
order.sort((a, b) => totals[b] - totals[a]);
|
|
@@ -2351,9 +326,16 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
2351
326
|
let finalCategories;
|
|
2352
327
|
let finalSeries;
|
|
2353
328
|
if (opts.topN && order.length > opts.topN) {
|
|
2354
|
-
const
|
|
2355
|
-
|
|
2356
|
-
|
|
329
|
+
const other = opts.otherLabel ?? 'Other';
|
|
330
|
+
// A real category may already be called "Other" (a survey's own bucket,
|
|
331
|
+
// a browser share's long tail). Two categories with one name is a
|
|
332
|
+
// duplicate key the renderer cannot draw, so the tail folds INTO that
|
|
333
|
+
// category rather than beside it: it is kept whether or not it made the
|
|
334
|
+
// top N, and the rest is added to it.
|
|
335
|
+
const own = order.find((i) => categories[i] === other);
|
|
336
|
+
const keep = order.slice(0, opts.topN).filter((i) => i !== own);
|
|
337
|
+
const rest = order.filter((i) => !keep.includes(i));
|
|
338
|
+
finalCategories = keep.map((i) => categories[i]).concat(other);
|
|
2357
339
|
finalSeries = entries.map((e) => ({
|
|
2358
340
|
label: e.label,
|
|
2359
341
|
values: keep
|
|
@@ -2381,6 +363,124 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
2381
363
|
stacked: opts.stacked,
|
|
2382
364
|
stacked100: opts.stacked100,
|
|
2383
365
|
palette: opts.palette,
|
|
366
|
+
...(opts.bucket ? { xType: 'ordinal-time' } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The bar-plus-line spec a pareto chart draws: categories sorted by value,
|
|
371
|
+
* bars for the values, and a cumulative-share line on the right axis pinned to
|
|
372
|
+
* 0..100 with a reference line at `threshold` (default 80, the "vital few").
|
|
373
|
+
* `buildChart` calls this for `type: 'pareto'`; call it yourself to get at the
|
|
374
|
+
* spec, or pass a ready spec whose first series is the values.
|
|
375
|
+
*/
|
|
376
|
+
export function paretoSpec(spec, threshold = 80) {
|
|
377
|
+
const src = spec.series[0];
|
|
378
|
+
if (!src)
|
|
379
|
+
return { ...spec, type: 'bar' };
|
|
380
|
+
const order = spec.categories.map((_, i) => i).sort((a, b) => (src.values[b] ?? 0) - (src.values[a] ?? 0));
|
|
381
|
+
const sorted = pickCategories(spec, order);
|
|
382
|
+
const values = sorted.series[0].values.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0));
|
|
383
|
+
const total = values.reduce((a, b) => a + b, 0) || 1;
|
|
384
|
+
let cum = 0;
|
|
385
|
+
const share = values.map((v) => {
|
|
386
|
+
cum += v;
|
|
387
|
+
return Math.round((cum / total) * 1000) / 10;
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
...sorted,
|
|
391
|
+
type: 'bar',
|
|
392
|
+
series: [
|
|
393
|
+
{ ...sorted.series[0], type: 'bar' },
|
|
394
|
+
{ label: 'Cumulative %', values: share, type: 'line', axis: 'right', marker: 'circle', color: sorted.series[0].overlayColor ?? '#ef4444' },
|
|
395
|
+
],
|
|
396
|
+
y2Axis: { min: 0, max: 100, tickInterval: 25, formatter: (v) => `${v}%`, ...(spec.y2Axis ?? {}) },
|
|
397
|
+
referenceLines: [
|
|
398
|
+
...(spec.referenceLines ?? []),
|
|
399
|
+
...(threshold > 0 ? [{ value: threshold, axis: 'right', label: `${threshold}%`, dashed: true }] : []),
|
|
400
|
+
],
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Bin one numeric field of the rows into a histogram spec. The categories are
|
|
405
|
+
* the bin midpoints on a numeric axis and `binEdges` labels the edges, so the
|
|
406
|
+
* bars touch and the axis reads `0, 10, 20` rather than `5, 15, 25`.
|
|
407
|
+
* `series` splits the sample into one histogram per distinct value.
|
|
408
|
+
*/
|
|
409
|
+
export function rowsToHistogramSpec(rows, opts) {
|
|
410
|
+
const all = rows.map((r) => Number(r[opts.value])).filter(Number.isFinite);
|
|
411
|
+
// Bin the whole sample once so every split shares the same edges.
|
|
412
|
+
const bins = binValues(all, { bins: opts.bins, binWidth: opts.binWidth, method: opts.method, min: opts.min, max: opts.max });
|
|
413
|
+
const groups = opts.series
|
|
414
|
+
? [...new Set(rows.map((r) => String(r[opts.series] ?? '')))].map((g) => ({ label: g, rows: rows.filter((r) => String(r[opts.series] ?? '') === g) }))
|
|
415
|
+
: [{ label: opts.seriesLabel ?? String(opts.value), rows }];
|
|
416
|
+
const series = groups.map((g) => {
|
|
417
|
+
const counts = new Array(bins.counts.length).fill(0);
|
|
418
|
+
const lo = bins.edges[0] ?? 0;
|
|
419
|
+
for (const r of g.rows) {
|
|
420
|
+
const n = Number(r[opts.value]);
|
|
421
|
+
if (!Number.isFinite(n) || !bins.width)
|
|
422
|
+
continue;
|
|
423
|
+
let i = Math.floor((n - lo) / bins.width);
|
|
424
|
+
if (i >= counts.length)
|
|
425
|
+
i = counts.length - 1;
|
|
426
|
+
if (i < 0)
|
|
427
|
+
i = 0;
|
|
428
|
+
counts[i] = (counts[i] ?? 0) + 1;
|
|
429
|
+
}
|
|
430
|
+
return { label: g.label, values: counts };
|
|
431
|
+
});
|
|
432
|
+
return {
|
|
433
|
+
type: 'histogram',
|
|
434
|
+
categories: bins.categories,
|
|
435
|
+
series,
|
|
436
|
+
binEdges: bins.edges,
|
|
437
|
+
xType: 'number',
|
|
438
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
439
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
440
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* A range (floating) bar or area spec from rows: one row per category with a
|
|
445
|
+
* low field and a high field, e.g. a day's low and high temperature, or a
|
|
446
|
+
* salary band. `series` splits into one range per distinct value.
|
|
447
|
+
*/
|
|
448
|
+
export function rowsToRangeSpec(rows, opts) {
|
|
449
|
+
const cats = [];
|
|
450
|
+
const seen = new Set();
|
|
451
|
+
for (const r of rows) {
|
|
452
|
+
const c = String(r[opts.category] ?? '');
|
|
453
|
+
if (!seen.has(c)) {
|
|
454
|
+
seen.add(c);
|
|
455
|
+
cats.push(c);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const names = opts.series
|
|
459
|
+
? [...new Set(rows.map((r) => String(r[opts.series] ?? '')))]
|
|
460
|
+
: [opts.seriesLabel ?? `${String(opts.low)} to ${String(opts.high)}`];
|
|
461
|
+
const series = names.map((name) => {
|
|
462
|
+
const lows = new Array(cats.length).fill(Number.NaN);
|
|
463
|
+
const highs = new Array(cats.length).fill(Number.NaN);
|
|
464
|
+
for (const r of rows) {
|
|
465
|
+
if (opts.series && String(r[opts.series] ?? '') !== name)
|
|
466
|
+
continue;
|
|
467
|
+
const i = cats.indexOf(String(r[opts.category] ?? ''));
|
|
468
|
+
const lo = Number(r[opts.low]);
|
|
469
|
+
const hi = Number(r[opts.high]);
|
|
470
|
+
if (i < 0 || !Number.isFinite(lo) || !Number.isFinite(hi))
|
|
471
|
+
continue;
|
|
472
|
+
lows[i] = Math.min(lo, hi);
|
|
473
|
+
highs[i] = Math.max(lo, hi);
|
|
474
|
+
}
|
|
475
|
+
return { label: name, values: highs, lowValues: lows };
|
|
476
|
+
});
|
|
477
|
+
return {
|
|
478
|
+
type: opts.type ?? 'range-bar',
|
|
479
|
+
categories: cats,
|
|
480
|
+
series,
|
|
481
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
482
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
483
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
2384
484
|
};
|
|
2385
485
|
}
|
|
2386
486
|
// ---------------------------------------------------------------------------
|
|
@@ -2476,6 +576,108 @@ export function specToSankey(spec) {
|
|
|
2476
576
|
links,
|
|
2477
577
|
};
|
|
2478
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Responsive rules the builder offers by name. `compact(maxWidth)` is the
|
|
581
|
+
* one the Format tab writes through `compactBelow`: the labels that crowd a
|
|
582
|
+
* narrow chart go, the category labels turn vertical, and the legend hides.
|
|
583
|
+
*/
|
|
584
|
+
export const CHART_RESPONSIVE_PRESETS = {
|
|
585
|
+
compact: (maxWidth) => ({
|
|
586
|
+
maxWidth,
|
|
587
|
+
spec: { seriesLabels: false, dataLabels: { show: false }, xAxis: { labelRotation: 90 } },
|
|
588
|
+
legend: false,
|
|
589
|
+
}),
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Apply a {@link ChartFormatState} to a spec: a new spec with the titles,
|
|
593
|
+
* axis settings, palette and per-series styling merged in. Pure, so the
|
|
594
|
+
* panel can run it on every derived spec and a saved format keeps applying
|
|
595
|
+
* to fresh data. Unknown series labels are ignored.
|
|
596
|
+
*/
|
|
597
|
+
export function applyChartFormat(spec, format) {
|
|
598
|
+
if (!format)
|
|
599
|
+
return spec;
|
|
600
|
+
const out = { ...spec };
|
|
601
|
+
if (format.title !== undefined)
|
|
602
|
+
out.title = format.title || undefined;
|
|
603
|
+
if (format.subtitle !== undefined)
|
|
604
|
+
out.subtitle = format.subtitle || undefined;
|
|
605
|
+
if (format.caption !== undefined)
|
|
606
|
+
out.caption = format.caption || undefined;
|
|
607
|
+
if (format.palette)
|
|
608
|
+
out.palette = format.palette;
|
|
609
|
+
if (format.seriesLabels !== undefined)
|
|
610
|
+
out.seriesLabels = format.seriesLabels;
|
|
611
|
+
if (format.style)
|
|
612
|
+
out.style = { ...spec.style, ...format.style };
|
|
613
|
+
if (format.compactBelow != null && format.compactBelow > 0) {
|
|
614
|
+
out.responsive = [...(spec.responsive ?? []), CHART_RESPONSIVE_PRESETS.compact(format.compactBelow)];
|
|
615
|
+
}
|
|
616
|
+
const axis = (base, f) => {
|
|
617
|
+
if (!f)
|
|
618
|
+
return base;
|
|
619
|
+
const next = { ...base };
|
|
620
|
+
if (f.min !== undefined) {
|
|
621
|
+
if (f.min === null)
|
|
622
|
+
delete next.min;
|
|
623
|
+
else
|
|
624
|
+
next.min = f.min;
|
|
625
|
+
}
|
|
626
|
+
if (f.max !== undefined) {
|
|
627
|
+
if (f.max === null)
|
|
628
|
+
delete next.max;
|
|
629
|
+
else
|
|
630
|
+
next.max = f.max;
|
|
631
|
+
}
|
|
632
|
+
if (f.title !== undefined)
|
|
633
|
+
next.title = f.title || undefined;
|
|
634
|
+
if (f.gridLines !== undefined)
|
|
635
|
+
next.gridLines = f.gridLines;
|
|
636
|
+
if (f.format !== undefined)
|
|
637
|
+
next.format = f.format;
|
|
638
|
+
return next;
|
|
639
|
+
};
|
|
640
|
+
if (format.xAxis) {
|
|
641
|
+
const next = { ...out.xAxis };
|
|
642
|
+
if (format.xAxis.title !== undefined)
|
|
643
|
+
next.title = format.xAxis.title || undefined;
|
|
644
|
+
if (format.xAxis.gridLines !== undefined)
|
|
645
|
+
next.gridLines = format.xAxis.gridLines;
|
|
646
|
+
if (format.xAxis.labelRotation !== undefined)
|
|
647
|
+
next.labelRotation = format.xAxis.labelRotation;
|
|
648
|
+
out.xAxis = next;
|
|
649
|
+
}
|
|
650
|
+
if (format.yAxis)
|
|
651
|
+
out.yAxis = axis(out.yAxis, format.yAxis);
|
|
652
|
+
if (format.y2Axis)
|
|
653
|
+
out.y2Axis = axis(out.y2Axis, format.y2Axis);
|
|
654
|
+
if (format.series) {
|
|
655
|
+
out.series = spec.series.map((s) => {
|
|
656
|
+
const f = format.series[s.label];
|
|
657
|
+
if (!f)
|
|
658
|
+
return s;
|
|
659
|
+
const next = { ...s };
|
|
660
|
+
if (f.color)
|
|
661
|
+
next.color = f.color;
|
|
662
|
+
if (f.type)
|
|
663
|
+
next.type = f.type;
|
|
664
|
+
if (f.axis)
|
|
665
|
+
next.axis = f.axis;
|
|
666
|
+
if (f.marker)
|
|
667
|
+
next.marker = f.marker;
|
|
668
|
+
if (f.strokeWidth !== undefined)
|
|
669
|
+
next.strokeWidth = f.strokeWidth;
|
|
670
|
+
if (f.dash !== undefined)
|
|
671
|
+
next.dash = f.dash || undefined;
|
|
672
|
+
if (f.smooth !== undefined)
|
|
673
|
+
next.smooth = f.smooth;
|
|
674
|
+
if (f.stack !== undefined)
|
|
675
|
+
next.stack = f.stack || undefined;
|
|
676
|
+
return next;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
2479
681
|
/**
|
|
2480
682
|
* The chart types that read ROWS directly instead of a grouped grid, behind one
|
|
2481
683
|
* call. Returns `null` for every other type, which then goes through
|
|
@@ -2491,6 +693,20 @@ export function rowsToDirectSpec(type, rows, opts) {
|
|
|
2491
693
|
const cat = opts.category;
|
|
2492
694
|
const val = opts.value;
|
|
2493
695
|
const ser = opts.series;
|
|
696
|
+
if (type === 'candlestick' || type === 'ohlc') {
|
|
697
|
+
const o = opts.ohlc;
|
|
698
|
+
if (!cat || !o)
|
|
699
|
+
return null;
|
|
700
|
+
const k = (f) => f;
|
|
701
|
+
return rowsToOhlcSpec(rows, {
|
|
702
|
+
date: cat, open: k(o.open), high: k(o.high), low: k(o.low), close: k(o.close),
|
|
703
|
+
...(o.volume ? { volume: k(o.volume) } : {}),
|
|
704
|
+
...(o.bucket ? { bucket: o.bucket } : {}),
|
|
705
|
+
...(o.candleStyle ? { candleStyle: o.candleStyle } : {}),
|
|
706
|
+
...(o.lastPriceLine ? { lastPriceLine: true } : {}),
|
|
707
|
+
type,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
2494
710
|
if (type === 'scatter') {
|
|
2495
711
|
const y = opts.value2;
|
|
2496
712
|
if (!val || !y)
|
|
@@ -2519,6 +735,49 @@ export function rowsToDirectSpec(type, rows, opts) {
|
|
|
2519
735
|
spec.palette = opts.palette;
|
|
2520
736
|
return spec;
|
|
2521
737
|
}
|
|
738
|
+
if (type === 'histogram') {
|
|
739
|
+
if (!val)
|
|
740
|
+
return null;
|
|
741
|
+
return rowsToHistogramSpec(rows, {
|
|
742
|
+
value: val,
|
|
743
|
+
...(ser ? { series: ser } : {}),
|
|
744
|
+
...(opts.bins ? { bins: opts.bins } : {}),
|
|
745
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
if (type === 'range-bar' || type === 'range-area' || type === 'dumbbell') {
|
|
749
|
+
// Two measures: the first is the low end, the second the high end, each
|
|
750
|
+
// reduced per category the way a bar would be.
|
|
751
|
+
const hi = opts.value2;
|
|
752
|
+
if (!cat || !val || !hi)
|
|
753
|
+
return null;
|
|
754
|
+
const lows = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' });
|
|
755
|
+
const highs = rowsToChartSpec(rows, { type: 'bar', category: cat, value: hi, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' });
|
|
756
|
+
return {
|
|
757
|
+
type,
|
|
758
|
+
categories: highs.categories,
|
|
759
|
+
series: highs.series.map((s, i) => ({
|
|
760
|
+
...s,
|
|
761
|
+
label: ser ? s.label : `${String(val)} to ${String(hi)}`,
|
|
762
|
+
lowValues: lows.series[i]?.values.map((v, k) => Math.min(v, s.values[k] ?? v)) ?? [],
|
|
763
|
+
values: s.values.map((v, k) => Math.max(v, lows.series[i]?.values[k] ?? v)),
|
|
764
|
+
})),
|
|
765
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (type === 'bullet') {
|
|
769
|
+
if (!cat || !val)
|
|
770
|
+
return null;
|
|
771
|
+
const measure = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, reduce: opts.reduce ?? 'sum' });
|
|
772
|
+
const t = opts.value2;
|
|
773
|
+
const target = t ? rowsToChartSpec(rows, { type: 'bar', category: cat, value: t, reduce: opts.reduce ?? 'sum' }) : null;
|
|
774
|
+
return {
|
|
775
|
+
type: 'bullet',
|
|
776
|
+
categories: measure.categories,
|
|
777
|
+
series: [{ ...measure.series[0], ...(target ? { targets: target.series[0].values } : {}) }],
|
|
778
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
2522
781
|
return null;
|
|
2523
782
|
}
|
|
2524
783
|
/**
|
|
@@ -2640,17 +899,14 @@ export function rowsToScatterSpec(rows, opts) {
|
|
|
2640
899
|
* the value, so the needle never sits pinned at the far end of the arc.
|
|
2641
900
|
*/
|
|
2642
901
|
export function rowsToGaugeSpec(rows, opts) {
|
|
2643
|
-
const
|
|
2644
|
-
let sum = 0;
|
|
2645
|
-
let count = 0;
|
|
902
|
+
const samples = [];
|
|
2646
903
|
for (const row of rows) {
|
|
2647
904
|
const v = Number(row[opts.value]);
|
|
2648
|
-
if (
|
|
2649
|
-
|
|
2650
|
-
sum += v;
|
|
2651
|
-
count += 1;
|
|
905
|
+
if (Number.isFinite(v))
|
|
906
|
+
samples.push(v);
|
|
2652
907
|
}
|
|
2653
|
-
const
|
|
908
|
+
const reduced = reduceValues(samples, opts.reduce ?? 'sum');
|
|
909
|
+
const value = Number.isFinite(reduced) ? reduced : 0;
|
|
2654
910
|
const min = opts.min ?? Math.min(0, value);
|
|
2655
911
|
const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1);
|
|
2656
912
|
return {
|