@svgrid/grid 3.0.2 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
- package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
- package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
- package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
- package/dist/cdn/chart-CKJRCPOk.js +0 -1652
|
@@ -0,0 +1,1863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cartesian layouts: bar / line / area (combo, stacked, dual axis), horizontal
|
|
3
|
+
* bars and scatter. Every mark is positioned in SVG coordinates against the
|
|
4
|
+
* axes chart-axes.ts resolves.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
ChartSeriesLabel,
|
|
8
|
+
ChartAxisTick,
|
|
9
|
+
ChartBar,
|
|
10
|
+
ChartBox,
|
|
11
|
+
ChartBullet,
|
|
12
|
+
ChartCandle,
|
|
13
|
+
ChartCategoryTick,
|
|
14
|
+
ChartDataLabelConfig,
|
|
15
|
+
ChartDrawingGeo,
|
|
16
|
+
ChartErrorBar,
|
|
17
|
+
ChartGeometry,
|
|
18
|
+
ChartLine,
|
|
19
|
+
ChartLinePoint,
|
|
20
|
+
ChartLineStyle,
|
|
21
|
+
ChartMarker,
|
|
22
|
+
ChartMarkerShape,
|
|
23
|
+
ChartRefBandGeo,
|
|
24
|
+
ChartRefLineGeo,
|
|
25
|
+
ChartRefLineGeoV,
|
|
26
|
+
ChartScatterDot,
|
|
27
|
+
ChartSeries,
|
|
28
|
+
ChartSpec,
|
|
29
|
+
ChartStem,
|
|
30
|
+
LayoutCtx,
|
|
31
|
+
NiceScale,
|
|
32
|
+
OhlcBar,
|
|
33
|
+
ResolvedAxis,
|
|
34
|
+
ResolvedSeries,
|
|
35
|
+
} from './chart-types'
|
|
36
|
+
import {
|
|
37
|
+
axisScale,
|
|
38
|
+
dateTicks,
|
|
39
|
+
logTicks,
|
|
40
|
+
niceLogScale,
|
|
41
|
+
errorSpan,
|
|
42
|
+
fmtDate,
|
|
43
|
+
fmtTick,
|
|
44
|
+
formatChartValue,
|
|
45
|
+
ordinalDateTicks,
|
|
46
|
+
project,
|
|
47
|
+
round,
|
|
48
|
+
thinCategoryTicks,
|
|
49
|
+
} from './chart-scale'
|
|
50
|
+
import { bollingerBands, computeOverlayFit, regressionFit } from './chart-indicators'
|
|
51
|
+
import { axisDomain, axisTickLabel } from './chart-axes'
|
|
52
|
+
import { heikinAshi } from './chart-financial'
|
|
53
|
+
|
|
54
|
+
type XY = { x: number; y: number }
|
|
55
|
+
type Step = NonNullable<ChartSeries['step']>
|
|
56
|
+
|
|
57
|
+
// ---- Path builders --------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
61
|
+
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
62
|
+
* Breaks the path at `defined === false` gaps unless `connectNulls` is set,
|
|
63
|
+
* and draws steps instead of straight segments when `step` is set.
|
|
64
|
+
*/
|
|
65
|
+
export function buildLinePath(
|
|
66
|
+
pts: Array<{ x: number; y: number; defined: boolean }>,
|
|
67
|
+
smooth: boolean,
|
|
68
|
+
opts: { connectNulls?: boolean; step?: Step } = {},
|
|
69
|
+
): string {
|
|
70
|
+
const runs = definedRuns(pts, !!opts.connectNulls)
|
|
71
|
+
return runs.map((run) => runPath(run, smooth, opts.step)).filter(Boolean).join(' ')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Split points into runs of defined points; one run when gaps are bridged. */
|
|
75
|
+
function definedRuns(
|
|
76
|
+
pts: Array<{ x: number; y: number; defined: boolean }>,
|
|
77
|
+
connect: boolean,
|
|
78
|
+
): XY[][] {
|
|
79
|
+
const runs: XY[][] = []
|
|
80
|
+
let cur: XY[] = []
|
|
81
|
+
for (const p of pts) {
|
|
82
|
+
if (p.defined) cur.push({ x: p.x, y: p.y })
|
|
83
|
+
else if (cur.length && !connect) {
|
|
84
|
+
runs.push(cur)
|
|
85
|
+
cur = []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (cur.length) runs.push(cur)
|
|
89
|
+
return runs
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The path for one contiguous run: straight, smoothed or stepped. */
|
|
93
|
+
function runPath(run: XY[], smooth: boolean, step?: Step): string {
|
|
94
|
+
if (!run.length) return ''
|
|
95
|
+
if (step) return polyline(stepPoints(run, step))
|
|
96
|
+
if (smooth) return monotoneCubicPath(run)
|
|
97
|
+
return polyline(run)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function polyline(pts: XY[]): string {
|
|
101
|
+
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Insert the corner point between each pair so the run draws as steps. */
|
|
105
|
+
function stepPoints(run: XY[], step: Step): XY[] {
|
|
106
|
+
const out: XY[] = []
|
|
107
|
+
for (let i = 0; i < run.length; i += 1) {
|
|
108
|
+
const p = run[i]!
|
|
109
|
+
if (i > 0) {
|
|
110
|
+
const q = run[i - 1]!
|
|
111
|
+
if (step === 'before') out.push({ x: q.x, y: p.y })
|
|
112
|
+
else if (step === 'after') out.push({ x: p.x, y: q.y })
|
|
113
|
+
else {
|
|
114
|
+
const mx = round((q.x + p.x) / 2)
|
|
115
|
+
out.push({ x: mx, y: q.y }, { x: mx, y: p.y })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
out.push(p)
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A closed shape between a top run and a bottom run (an area over its
|
|
125
|
+
* baseline, a band between two envelopes), in the run's own style.
|
|
126
|
+
*/
|
|
127
|
+
function closedBetween(top: XY[], bottom: XY[], smooth: boolean, step?: Step): string {
|
|
128
|
+
if (!top.length || !bottom.length) return ''
|
|
129
|
+
const up = runPath(top, smooth, step)
|
|
130
|
+
const back = runPath(bottom.slice().reverse(), smooth, step).replace(/^M/, 'L')
|
|
131
|
+
return `${up} ${back} Z`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The baseline a stacked area sits on per category. `'zero'` is the axis;
|
|
136
|
+
* `'silhouette'` centres the total on zero; `'wiggle'` picks the baseline
|
|
137
|
+
* that minimises the weighted change of slope across the layers (Byron and
|
|
138
|
+
* Wattenberg's stream graph), which is what makes a stream read as flowing
|
|
139
|
+
* rather than as a stack. Gaps count as 0 so the stack stays continuous.
|
|
140
|
+
*/
|
|
141
|
+
export function streamBaseline(layers: ReadonlyArray<ReadonlyArray<number>>, offset: 'zero' | 'wiggle' | 'silhouette'): number[] {
|
|
142
|
+
const n = layers.length
|
|
143
|
+
const m = layers[0]?.length ?? 0
|
|
144
|
+
const base = new Array<number>(m).fill(0)
|
|
145
|
+
if (!n || !m || offset === 'zero') return base
|
|
146
|
+
const v = (i: number, j: number) => {
|
|
147
|
+
const x = layers[i]![j]
|
|
148
|
+
return Number.isFinite(x) ? (x as number) : 0
|
|
149
|
+
}
|
|
150
|
+
if (offset === 'silhouette') {
|
|
151
|
+
for (let j = 0; j < m; j += 1) {
|
|
152
|
+
let total = 0
|
|
153
|
+
for (let i = 0; i < n; i += 1) total += v(i, j)
|
|
154
|
+
base[j] = -total / 2
|
|
155
|
+
}
|
|
156
|
+
return base
|
|
157
|
+
}
|
|
158
|
+
let y = 0
|
|
159
|
+
for (let j = 1; j < m; j += 1) {
|
|
160
|
+
let s1 = 0
|
|
161
|
+
let s2 = 0
|
|
162
|
+
for (let i = 0; i < n; i += 1) {
|
|
163
|
+
const sij0 = v(i, j)
|
|
164
|
+
const sij1 = v(i, j - 1)
|
|
165
|
+
let s3 = (sij0 - sij1) / 2
|
|
166
|
+
for (let k = 0; k < i; k += 1) s3 += v(k, j) - v(k, j - 1)
|
|
167
|
+
s1 += sij0
|
|
168
|
+
s2 += s3 * sij0
|
|
169
|
+
}
|
|
170
|
+
base[j - 1] = y
|
|
171
|
+
if (s1) y -= s2 / s1
|
|
172
|
+
}
|
|
173
|
+
base[m - 1] = y
|
|
174
|
+
return base
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
178
|
+
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
179
|
+
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
180
|
+
* to the neighbours along that tangent. */
|
|
181
|
+
function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
|
|
182
|
+
const n = pts.length
|
|
183
|
+
if (n === 0) return ''
|
|
184
|
+
if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
|
|
185
|
+
if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
|
|
186
|
+
// Secant slopes between adjacent points.
|
|
187
|
+
const dx: number[] = new Array(n - 1)
|
|
188
|
+
const m: number[] = new Array(n - 1)
|
|
189
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
190
|
+
const d = pts[i + 1]!.x - pts[i]!.x
|
|
191
|
+
dx[i] = d
|
|
192
|
+
m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
|
|
193
|
+
}
|
|
194
|
+
// Tangent at each point: average of neighbouring slopes, with sign
|
|
195
|
+
// checks that flatten the tangent when slopes change sign.
|
|
196
|
+
const tan: number[] = new Array(n)
|
|
197
|
+
tan[0] = m[0]!
|
|
198
|
+
tan[n - 1] = m[n - 2]!
|
|
199
|
+
for (let i = 1; i < n - 1; i += 1) {
|
|
200
|
+
if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
|
|
201
|
+
else tan[i] = (m[i - 1]! + m[i]!) / 2
|
|
202
|
+
}
|
|
203
|
+
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
204
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
205
|
+
if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
|
|
206
|
+
const a = tan[i]! / m[i]!
|
|
207
|
+
const b = tan[i + 1]! / m[i]!
|
|
208
|
+
const h = Math.hypot(a, b)
|
|
209
|
+
if (h > 3) {
|
|
210
|
+
tan[i] = (3 / h) * a * m[i]!
|
|
211
|
+
tan[i + 1] = (3 / h) * b * m[i]!
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
215
|
+
let path = `M${pts[0]!.x},${pts[0]!.y}`
|
|
216
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
217
|
+
const h = dx[i]!
|
|
218
|
+
const c1x = pts[i]!.x + h / 3
|
|
219
|
+
const c1y = pts[i]!.y + (tan[i]! * h) / 3
|
|
220
|
+
const c2x = pts[i + 1]!.x - h / 3
|
|
221
|
+
const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
|
|
222
|
+
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
|
|
223
|
+
}
|
|
224
|
+
return path
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- Markers --------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The SVG path for a point marker of `shape` centred on (cx, cy) with
|
|
231
|
+
* half-size `size`. `'circle'` and `'none'` return `''`: circles draw as
|
|
232
|
+
* `<circle>` (which keeps every existing selector working), and `'none'` draws
|
|
233
|
+
* nothing. Exposed so a legend swatch or a custom mark can match the series.
|
|
234
|
+
*/
|
|
235
|
+
export function markerPath(shape: ChartMarkerShape, cx: number, cy: number, size: number): string {
|
|
236
|
+
const s = Math.max(0.5, size)
|
|
237
|
+
switch (shape) {
|
|
238
|
+
case 'square':
|
|
239
|
+
return `M${round(cx - s)},${round(cy - s)} h${round(2 * s)} v${round(2 * s)} h${round(-2 * s)} Z`
|
|
240
|
+
case 'diamond':
|
|
241
|
+
return `M${round(cx)},${round(cy - s * 1.3)} L${round(cx + s * 1.3)},${round(cy)} L${round(cx)},${round(cy + s * 1.3)} L${round(cx - s * 1.3)},${round(cy)} Z`
|
|
242
|
+
case 'triangle':
|
|
243
|
+
return `M${round(cx)},${round(cy - s * 1.2)} L${round(cx + s * 1.2)},${round(cy + s)} L${round(cx - s * 1.2)},${round(cy + s)} Z`
|
|
244
|
+
case 'cross':
|
|
245
|
+
return `M${round(cx - s)},${round(cy - s)} L${round(cx + s)},${round(cy + s)} M${round(cx - s)},${round(cy + s)} L${round(cx + s)},${round(cy - s)}`
|
|
246
|
+
default:
|
|
247
|
+
return ''
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The marker for point `i` of a series, or undefined for the default dot. */
|
|
252
|
+
function markerAt(s: ResolvedSeries, i: number): ChartMarker | undefined {
|
|
253
|
+
const base: ChartMarker = typeof s.marker === 'string' ? { shape: s.marker } : s.marker ?? {}
|
|
254
|
+
const over = s.markers?.[i] ?? undefined
|
|
255
|
+
const color = over?.color ?? base.color ?? s.colors?.[i] ?? undefined
|
|
256
|
+
const shape = over?.shape ?? base.shape
|
|
257
|
+
const size = over?.size ?? base.size
|
|
258
|
+
if (shape == null && size == null && color == null) return undefined
|
|
259
|
+
const m: ChartMarker = {}
|
|
260
|
+
if (shape != null) m.shape = shape
|
|
261
|
+
if (size != null) m.size = size
|
|
262
|
+
if (color != null) m.color = color
|
|
263
|
+
return m
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Stroke / fill style from the series, or undefined when it sets none. */
|
|
267
|
+
function styleOf(s: ResolvedSeries): ChartLineStyle | undefined {
|
|
268
|
+
const st: ChartLineStyle = {}
|
|
269
|
+
let any = false
|
|
270
|
+
if (s.strokeWidth != null) { st.strokeWidth = s.strokeWidth; any = true }
|
|
271
|
+
if (s.dash != null) { st.dash = Array.isArray(s.dash) ? s.dash.join(' ') : s.dash; any = true }
|
|
272
|
+
if (s.opacity != null) { st.opacity = s.opacity; any = true }
|
|
273
|
+
if (s.gradient) {
|
|
274
|
+
const g = s.gradient === true ? {} : s.gradient
|
|
275
|
+
st.gradient = { from: g.from ?? s.color, to: g.to ?? 'transparent' }
|
|
276
|
+
any = true
|
|
277
|
+
}
|
|
278
|
+
return any ? st : undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- Data labels ----------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/** One positioned data label. `onBar` means it sits on a mark and should be
|
|
284
|
+
* painted in the contrast colour. */
|
|
285
|
+
export type ChartDataLabel = {
|
|
286
|
+
x: number
|
|
287
|
+
y: number
|
|
288
|
+
text: string
|
|
289
|
+
anchor: 'start' | 'middle' | 'end'
|
|
290
|
+
onBar: boolean
|
|
291
|
+
series: string
|
|
292
|
+
/** Rotation in degrees about (x, y), from `dataLabels.rotation`. */
|
|
293
|
+
angle?: number
|
|
294
|
+
/** A leader from the mark to a label that was pushed away from it. */
|
|
295
|
+
leader?: { x1: number; y1: number; x2: number; y2: number }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Place the data labels for a laid-out cartesian chart: one per bar and one
|
|
300
|
+
* per defined line point, positioned by `placement`, formatted by `formatter`
|
|
301
|
+
* (or `fmt`), and thinned so no label overlaps an earlier one when
|
|
302
|
+
* `hideOverlap` is on (the default).
|
|
303
|
+
*
|
|
304
|
+
* Pure, so the renderer derives it and a test can assert on it. Pie slices
|
|
305
|
+
* label themselves (a percentage at the centroid) and are not handled here.
|
|
306
|
+
*/
|
|
307
|
+
export function layoutDataLabels(
|
|
308
|
+
geo: ChartGeometry,
|
|
309
|
+
cfg: ChartDataLabelConfig,
|
|
310
|
+
fmt: (v: number, series?: string) => string,
|
|
311
|
+
opts: { stacked?: boolean; dense?: boolean; share?: boolean } = {},
|
|
312
|
+
): ChartDataLabel[] {
|
|
313
|
+
if (cfg.show === false) return []
|
|
314
|
+
const out: ChartDataLabel[] = []
|
|
315
|
+
// On a 100% chart the axis reads shares whatever `valueFormat` says, and a
|
|
316
|
+
// label on the bar reads the same: "18%", not the "$18" (or, with a percent
|
|
317
|
+
// format, the "1800%") the raw value gave. A formatter still gets the raw
|
|
318
|
+
// value; the tooltip keeps it too.
|
|
319
|
+
const totals = opts.share ? new Map<string, number>() : null
|
|
320
|
+
if (totals) {
|
|
321
|
+
for (const b of geo.bars) {
|
|
322
|
+
const key = b.index === undefined ? b.label : String(b.index)
|
|
323
|
+
totals.set(key, (totals.get(key) ?? 0) + Math.abs(b.value))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const shareOf = (b: ChartBar) => {
|
|
327
|
+
const total = totals!.get(b.index === undefined ? b.label : String(b.index)) || 0
|
|
328
|
+
return total ? `${Math.round((Math.abs(b.value) / total) * 100)}%` : ''
|
|
329
|
+
}
|
|
330
|
+
const text = (v: number, category: string, series: string, bar?: ChartBar) =>
|
|
331
|
+
cfg.formatter ? cfg.formatter(v, { category, series }) : totals && bar ? shareOf(bar) : fmt(v, series)
|
|
332
|
+
const horizontal = geo.orientation === 'horizontal'
|
|
333
|
+
const placement = cfg.placement ?? (opts.stacked ? 'inside' : horizontal ? 'outside' : 'top')
|
|
334
|
+
const inside = placement === 'inside' || placement === 'center'
|
|
335
|
+
for (const b of geo.bars) {
|
|
336
|
+
const t = text(b.value, b.label, b.series, b)
|
|
337
|
+
if (!t) continue
|
|
338
|
+
if (horizontal) {
|
|
339
|
+
if (inside) {
|
|
340
|
+
if (b.w <= 18 || b.h < 8) continue
|
|
341
|
+
out.push({ x: b.x + b.w / 2, y: b.y + b.h / 2 + 3, text: t, anchor: 'middle', onBar: true, series: b.series })
|
|
342
|
+
} else {
|
|
343
|
+
if (b.h < 8) continue
|
|
344
|
+
const pos = b.value >= 0
|
|
345
|
+
out.push({ x: pos ? b.x + b.w + 3 : b.x - 3, y: b.y + b.h / 2 + 3, text: t, anchor: pos ? 'start' : 'end', onBar: false, series: b.series })
|
|
346
|
+
}
|
|
347
|
+
} else if (inside) {
|
|
348
|
+
if (b.h <= 13 || b.w < 14) continue
|
|
349
|
+
out.push({ x: b.x + b.w / 2, y: b.y + b.h / 2 + 3, text: t, anchor: 'middle', onBar: true, series: b.series })
|
|
350
|
+
} else {
|
|
351
|
+
if (b.w < 6) continue
|
|
352
|
+
const pos = b.value >= 0
|
|
353
|
+
out.push({ x: b.x + b.w / 2, y: pos ? b.y - 3 : b.y + b.h + 11, text: t, anchor: 'middle', onBar: false, series: b.series })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!opts.dense) {
|
|
357
|
+
for (const line of geo.lines) {
|
|
358
|
+
for (const p of line.points) {
|
|
359
|
+
if (!p.defined) continue
|
|
360
|
+
const t = text(p.value, p.label, line.label)
|
|
361
|
+
if (!t) continue
|
|
362
|
+
out.push({ x: p.x, y: inside ? p.y + 3 : p.y - 7, text: t, anchor: 'middle', onBar: false, series: line.label })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const angle = cfg.rotation ?? 0
|
|
367
|
+
if (angle) for (const l of out) l.angle = angle
|
|
368
|
+
if (cfg.hideOverlap === false && !cfg.connector) return out
|
|
369
|
+
// Greedy thinning in draw order: a label is kept only when its estimated
|
|
370
|
+
// box (10px type, ~5.6px a glyph) misses every box already kept. A rotated
|
|
371
|
+
// label past 45 degrees stands more than it lies, so its box is turned.
|
|
372
|
+
// With `connector`, a label that collides is pushed away from its mark in
|
|
373
|
+
// 12px steps (up, or right on a horizontal chart) and keeps a leader back
|
|
374
|
+
// to where it started; after four steps it gives up and is dropped.
|
|
375
|
+
const kept: Array<{ x0: number; y0: number; x1: number; y1: number }> = []
|
|
376
|
+
const steep = Math.abs(angle) >= 45
|
|
377
|
+
const boxOf = (l: ChartDataLabel) => {
|
|
378
|
+
const len = l.text.length * 5.6 + 2
|
|
379
|
+
const w = steep ? 10 : len
|
|
380
|
+
const h = steep ? len : 11
|
|
381
|
+
const x0 = l.anchor === 'middle' ? l.x - w / 2 : l.anchor === 'end' ? l.x - w : l.x
|
|
382
|
+
const y0 = steep ? l.y - h : l.y - 10
|
|
383
|
+
return { x0, y0, x1: x0 + w, y1: y0 + h }
|
|
384
|
+
}
|
|
385
|
+
const hits = (b: { x0: number; y0: number; x1: number; y1: number }) =>
|
|
386
|
+
kept.some((k) => b.x0 < k.x1 && b.x1 > k.x0 && b.y0 < k.y1 && b.y1 > k.y0)
|
|
387
|
+
const STEP = 12
|
|
388
|
+
const MAX_STEPS = 4
|
|
389
|
+
const pushed: ChartDataLabel[] = []
|
|
390
|
+
for (const l of out) {
|
|
391
|
+
let box = boxOf(l)
|
|
392
|
+
if (!hits(box)) { kept.push(box); pushed.push(l); continue }
|
|
393
|
+
if (!cfg.connector || l.onBar) continue
|
|
394
|
+
const from = { x: l.x, y: l.y }
|
|
395
|
+
let moved: ChartDataLabel | null = null
|
|
396
|
+
for (let step = 1; step <= MAX_STEPS; step += 1) {
|
|
397
|
+
const cand: ChartDataLabel = horizontal
|
|
398
|
+
? { ...l, x: l.x + STEP * step }
|
|
399
|
+
: { ...l, y: l.y - STEP * step }
|
|
400
|
+
box = boxOf(cand)
|
|
401
|
+
if (!hits(box)) { moved = cand; break }
|
|
402
|
+
}
|
|
403
|
+
if (!moved) continue
|
|
404
|
+
// The leader runs from the mark's edge (where the label started) to the
|
|
405
|
+
// near edge of the moved label.
|
|
406
|
+
moved.leader = horizontal
|
|
407
|
+
? { x1: from.x, y1: from.y - 3, x2: moved.x - 2, y2: moved.y - 3 }
|
|
408
|
+
: { x1: from.x, y1: from.y + 1, x2: moved.x, y2: moved.y + 2 }
|
|
409
|
+
kept.push(box)
|
|
410
|
+
pushed.push(moved)
|
|
411
|
+
}
|
|
412
|
+
return pushed
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- The x axis -----------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
type XLayout = {
|
|
418
|
+
/** Positions by value (time or number) rather than by index. */
|
|
419
|
+
continuous: 'time' | 'number' | null
|
|
420
|
+
/** A logarithmic number axis. */
|
|
421
|
+
log?: boolean
|
|
422
|
+
/** Parsed per-category values on a continuous or ordinal-time axis. */
|
|
423
|
+
values: number[] | null
|
|
424
|
+
min: number
|
|
425
|
+
max: number
|
|
426
|
+
/** Pixel x at the centre of category `i`. */
|
|
427
|
+
xCenter: (i: number) => number
|
|
428
|
+
/** Pixel x for a value on a continuous axis (NaN otherwise). */
|
|
429
|
+
xOfValue: (v: number) => number
|
|
430
|
+
ticks: ChartCategoryTick[]
|
|
431
|
+
/** Uniform room per category. */
|
|
432
|
+
slot: number
|
|
433
|
+
/** Room a bar group gets: the slot, or the smallest gap between two
|
|
434
|
+
* neighbouring values on a numeric axis. */
|
|
435
|
+
barSlot: number
|
|
436
|
+
/** Left edge of category `i`'s bar group. */
|
|
437
|
+
barX0: (i: number) => number
|
|
438
|
+
rotated: boolean
|
|
439
|
+
angle: number
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Lay out the x axis for the cartesian core: where each category sits, how
|
|
444
|
+
* wide a bar group may be, and which ticks to label.
|
|
445
|
+
*
|
|
446
|
+
* Four modes. `'category'` and `'ordinal-time'` position by index (the latter
|
|
447
|
+
* labels from the dates); `'time'` positions by timestamp; `'number'` by the
|
|
448
|
+
* parsed value. Either parsing mode falls back to the category axis when no
|
|
449
|
+
* category parses, so a stray non-date does not blank the chart.
|
|
450
|
+
*/
|
|
451
|
+
function layoutX(
|
|
452
|
+
spec: ChartSpec,
|
|
453
|
+
ax: ResolvedAxis,
|
|
454
|
+
padL: number,
|
|
455
|
+
plotW: number,
|
|
456
|
+
autoRotated: boolean,
|
|
457
|
+
): XLayout {
|
|
458
|
+
const cats = spec.categories
|
|
459
|
+
const n = cats.length
|
|
460
|
+
const slot = plotW / Math.max(1, n)
|
|
461
|
+
const reversed = ax.reversed
|
|
462
|
+
const byIndex = (i: number) => {
|
|
463
|
+
const x = padL + slot * i + slot / 2
|
|
464
|
+
return round(reversed ? padL + plotW - (x - padL) : x)
|
|
465
|
+
}
|
|
466
|
+
const barByIndex = (i: number) => {
|
|
467
|
+
const x = padL + slot * i
|
|
468
|
+
return reversed ? padL + plotW - (x - padL) - slot : x
|
|
469
|
+
}
|
|
470
|
+
const angleOf = (rot: boolean) =>
|
|
471
|
+
typeof ax.labelRotation === 'number' ? ax.labelRotation : rot ? -40 : 0
|
|
472
|
+
const labelsOn = ax.labels
|
|
473
|
+
const fmtX = (v: number, i: number) => (ax.formatter ? ax.formatter(v, i) : ax.format ? formatChartValue(v, ax.format, spec) : fmtTick(v))
|
|
474
|
+
|
|
475
|
+
if (ax.type === 'number') {
|
|
476
|
+
const vals = cats.map((c) => Number(c))
|
|
477
|
+
const finite = vals.filter(Number.isFinite)
|
|
478
|
+
// A histogram's categories are bin midpoints; its axis runs edge to edge
|
|
479
|
+
// and is labelled at the edges, so the domain is the edges' extent. Bins
|
|
480
|
+
// stay linear: a log axis would squeeze the low bins to nothing.
|
|
481
|
+
const edges = spec.binEdges?.filter(Number.isFinite) ?? []
|
|
482
|
+
const positive = finite.filter((v) => v > 0)
|
|
483
|
+
const isLog = ax.scale === 'log' && edges.length < 2 && positive.length > 0
|
|
484
|
+
if (isLog) {
|
|
485
|
+
// Decades from the smallest positive value to the largest; `min` / `max`
|
|
486
|
+
// pin either end when they are positive too. Non-positive categories
|
|
487
|
+
// have no place on the axis and land at NaN, which the marks skip.
|
|
488
|
+
const nice = niceLogScale(Math.min(...positive), Math.max(...positive))
|
|
489
|
+
const lo = ax.min != null && ax.min > 0 ? ax.min : nice.min
|
|
490
|
+
const hi = ax.max != null && ax.max > lo ? ax.max : Math.max(nice.max, lo * 10)
|
|
491
|
+
const xOfValue = (v: number) => {
|
|
492
|
+
const t = project(v, lo, hi, true)
|
|
493
|
+
if (t === null) return Number.NaN
|
|
494
|
+
return round(padL + (reversed ? 1 - t : t) * plotW)
|
|
495
|
+
}
|
|
496
|
+
const xCenter = (i: number) => (Number.isFinite(vals[i]) && vals[i]! > 0 ? xOfValue(vals[i]!) : byIndex(i))
|
|
497
|
+
const px = [...new Set(positive)].map(xOfValue).sort((a, b) => a - b)
|
|
498
|
+
let minGap = Infinity
|
|
499
|
+
for (let i = 1; i < px.length; i += 1) minGap = Math.min(minGap, px[i]! - px[i - 1]!)
|
|
500
|
+
const barSlot = Number.isFinite(minGap) ? minGap : slot
|
|
501
|
+
const ticks: ChartCategoryTick[] = logTicks(lo, hi).map((v, i) => ({ label: labelsOn ? fmtX(v, i) : '', x: xOfValue(v) }))
|
|
502
|
+
return {
|
|
503
|
+
continuous: 'number', log: true, values: vals, min: lo, max: hi,
|
|
504
|
+
xCenter, xOfValue, ticks, slot, barSlot,
|
|
505
|
+
barX0: (i) => xCenter(i) - barSlot / 2,
|
|
506
|
+
rotated: false, angle: angleOf(false),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (finite.length) {
|
|
510
|
+
const dom = edges.length >= 2
|
|
511
|
+
? axisScale(Math.min(...edges), Math.max(...edges), { ...ax, nice: spec.xAxis?.nice === true })
|
|
512
|
+
: axisScale(Math.min(...finite), Math.max(...finite), ax)
|
|
513
|
+
const span = dom.max - dom.min || 1
|
|
514
|
+
const xOfValue = (v: number) => {
|
|
515
|
+
if (!Number.isFinite(v)) return Number.NaN
|
|
516
|
+
let t = (v - dom.min) / span
|
|
517
|
+
if (reversed) t = 1 - t
|
|
518
|
+
return round(padL + t * plotW)
|
|
519
|
+
}
|
|
520
|
+
const xCenter = (i: number) => (Number.isFinite(vals[i]) ? xOfValue(vals[i]!) : byIndex(i))
|
|
521
|
+
// Bars on a numeric axis sit where their value is, so the room a group
|
|
522
|
+
// gets is the smallest distance between two neighbouring values.
|
|
523
|
+
const sorted = [...new Set(finite)].sort((a, b) => a - b)
|
|
524
|
+
let minGap = Infinity
|
|
525
|
+
for (let i = 1; i < sorted.length; i += 1) minGap = Math.min(minGap, sorted[i]! - sorted[i - 1]!)
|
|
526
|
+
const barSlot = edges.length >= 2
|
|
527
|
+
? ((edges[1]! - edges[0]!) / span) * plotW
|
|
528
|
+
: Number.isFinite(minGap) ? (minGap / span) * plotW : slot
|
|
529
|
+
// Bins label their edges; anything else labels the nice ticks. A dense
|
|
530
|
+
// set of edges thins to about one label per 40px. An edge is written
|
|
531
|
+
// at the precision its width needs: the compact tick form gave a 44ms
|
|
532
|
+
// wide bin edges reading "1.3k, 1.3k", and 26.2 read as "26".
|
|
533
|
+
const tickVals = edges.length >= 2 ? thinNumbers(edges, Math.max(1, Math.floor(plotW / 40))) : dom.ticks
|
|
534
|
+
const edgeW = edges.length >= 2 ? Math.abs(edges[1]! - edges[0]!) : 1
|
|
535
|
+
const edgeDecimals = edgeW >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(edgeW)) + 1)
|
|
536
|
+
const fmtEdge = (v: number, i: number) =>
|
|
537
|
+
ax.formatter || ax.format ? fmtX(v, i) : v.toLocaleString(undefined, { minimumFractionDigits: edgeDecimals, maximumFractionDigits: edgeDecimals })
|
|
538
|
+
const ticks: ChartCategoryTick[] = tickVals.map((v, i) => ({ label: labelsOn ? (edges.length >= 2 ? fmtEdge(v, i) : fmtX(v, i)) : '', x: xOfValue(v) }))
|
|
539
|
+
return {
|
|
540
|
+
continuous: 'number', values: vals, min: dom.min, max: dom.max,
|
|
541
|
+
xCenter, xOfValue, ticks, slot, barSlot,
|
|
542
|
+
barX0: (i) => xCenter(i) - barSlot / 2,
|
|
543
|
+
rotated: false, angle: angleOf(false),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const isDate = ax.type === 'time' || ax.type === 'ordinal-time'
|
|
549
|
+
const timeVals = isDate ? cats.map((c) => Date.parse(c)) : null
|
|
550
|
+
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
551
|
+
if (timeOk && ax.type === 'time') {
|
|
552
|
+
const finite = timeVals!.filter(Number.isFinite)
|
|
553
|
+
const tMin = ax.min ?? Math.min(...finite)
|
|
554
|
+
const tMax = ax.max ?? Math.max(...finite)
|
|
555
|
+
const tSpan = tMax - tMin || 1
|
|
556
|
+
const xOfValue = (t: number) => {
|
|
557
|
+
if (!Number.isFinite(t)) return Number.NaN
|
|
558
|
+
let f = (t - tMin) / tSpan
|
|
559
|
+
if (reversed) f = 1 - f
|
|
560
|
+
return round(padL + f * plotW)
|
|
561
|
+
}
|
|
562
|
+
const xCenter = (i: number) => (Number.isFinite(timeVals![i]) ? xOfValue(timeVals![i]!) : byIndex(i))
|
|
563
|
+
// About one label per 90px: "Jun 25" is 40px of type and needs air.
|
|
564
|
+
const at = dateTicks(tMin, tMax, Math.max(2, Math.floor(plotW / 90)))
|
|
565
|
+
const step = at.length > 1 ? at[1]! - at[0]! : tSpan
|
|
566
|
+
const ticks: ChartCategoryTick[] = at.map((t, i) => ({
|
|
567
|
+
label: labelsOn ? (ax.formatter ? ax.formatter(t, i) : fmtDate(t, step)) : '',
|
|
568
|
+
x: xOfValue(t),
|
|
569
|
+
}))
|
|
570
|
+
// Bars stay uniform on a time axis (their width would otherwise depend on
|
|
571
|
+
// the calendar), which is what every existing time-axis chart drew.
|
|
572
|
+
return {
|
|
573
|
+
continuous: 'time', values: timeVals, min: tMin, max: tMax,
|
|
574
|
+
xCenter, xOfValue, ticks, slot, barSlot: slot, barX0: barByIndex,
|
|
575
|
+
rotated: false, angle: angleOf(false),
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (timeOk) {
|
|
579
|
+
// Ordinal: ticks land on points that exist, labelled from their dates.
|
|
580
|
+
const finite = timeVals!.filter(Number.isFinite)
|
|
581
|
+
const tSpan = Math.max(...finite) - Math.min(...finite) || 1
|
|
582
|
+
const at = ordinalDateTicks(timeVals!)
|
|
583
|
+
const step = at.length > 1 ? Math.abs(timeVals![at[1]!]! - timeVals![at[0]!]!) || tSpan : tSpan
|
|
584
|
+
const ticks: ChartCategoryTick[] = at.map((i, k) => ({
|
|
585
|
+
label: labelsOn ? (ax.formatter ? ax.formatter(timeVals![i]!, k) : fmtDate(timeVals![i]!, step)) : '',
|
|
586
|
+
x: byIndex(i),
|
|
587
|
+
}))
|
|
588
|
+
return {
|
|
589
|
+
continuous: null, values: timeVals, min: 0, max: n - 1,
|
|
590
|
+
xCenter: byIndex, xOfValue: () => Number.NaN, ticks, slot, barSlot: slot, barX0: barByIndex,
|
|
591
|
+
rotated: false, angle: angleOf(false),
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const angle = angleOf(autoRotated)
|
|
595
|
+
const rotated = angle !== 0
|
|
596
|
+
const ticks = labelsOn
|
|
597
|
+
? thinCategoryTicks(cats, byIndex, slot, rotated)
|
|
598
|
+
: []
|
|
599
|
+
return {
|
|
600
|
+
continuous: null, values: null, min: 0, max: n - 1,
|
|
601
|
+
xCenter: byIndex, xOfValue: () => Number.NaN, ticks, slot, barSlot: slot, barX0: barByIndex,
|
|
602
|
+
rotated, angle,
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Every n-th of a list, first and last always included. */
|
|
607
|
+
function thinNumbers(vals: number[], max: number): number[] {
|
|
608
|
+
if (vals.length <= max) return vals
|
|
609
|
+
const step = Math.ceil(vals.length / max)
|
|
610
|
+
const out = vals.filter((_, i) => i % step === 0)
|
|
611
|
+
if (out[out.length - 1] !== vals[vals.length - 1]) out.push(vals[vals.length - 1]!)
|
|
612
|
+
return out
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Resolve an x-axis reference value (label, date, number) to a pixel. */
|
|
616
|
+
function resolveXValue(spec: ChartSpec, xl: XLayout, value: number | string): number {
|
|
617
|
+
if (xl.continuous) {
|
|
618
|
+
const v = typeof value === 'number' ? value : xl.continuous === 'time' ? Date.parse(value) : Number(value)
|
|
619
|
+
if (Number.isFinite(v)) return xl.xOfValue(v)
|
|
620
|
+
}
|
|
621
|
+
if (typeof value === 'number') return xl.xCenter(value)
|
|
622
|
+
const i = spec.categories.indexOf(value)
|
|
623
|
+
if (i >= 0) return xl.xCenter(i)
|
|
624
|
+
// A date string on an ordinal axis: find the category with that day.
|
|
625
|
+
if (xl.values) {
|
|
626
|
+
const t = Date.parse(value)
|
|
627
|
+
if (Number.isFinite(t)) {
|
|
628
|
+
const j = xl.values.findIndex((v) => v === t)
|
|
629
|
+
if (j >= 0) return xl.xCenter(j)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return Number.NaN
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Bottom pad for the x axis labels at a given angle. */
|
|
636
|
+
function xLabelPad(spec: ChartSpec, ax: ResolvedAxis, xl: XLayout): number {
|
|
637
|
+
if (!ax.labels) return 8
|
|
638
|
+
if (!xl.rotated) return 28
|
|
639
|
+
if (typeof ax.labelRotation !== 'number') return 54
|
|
640
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, Math.min(c.length, 18)), 0)
|
|
641
|
+
const rad = (Math.abs(xl.angle) * Math.PI) / 180
|
|
642
|
+
return 28 + Math.round(Math.min(72, maxLabel * 6 * Math.sin(rad)))
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---- Scatter --------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
/** @internal Lay out the scatter family. Called by buildChart. */
|
|
648
|
+
export function layoutScatter(ctx: LayoutCtx): ChartGeometry {
|
|
649
|
+
const { spec, series, empty, width, height, frame, axes } = ctx
|
|
650
|
+
const padL = axes.y.width ?? (axes.y.labels ? 48 : 12) + (axes.y.title ? 16 : 0)
|
|
651
|
+
const padR = 12
|
|
652
|
+
const padT = 10 + frame.top
|
|
653
|
+
const padB = (axes.x.labels ? 28 : 8) + (axes.x.title ? 16 : 0) + frame.bottom
|
|
654
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
655
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
656
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
657
|
+
|
|
658
|
+
let xMin = Infinity
|
|
659
|
+
let xMax = -Infinity
|
|
660
|
+
let yMin = Infinity
|
|
661
|
+
let yMax = -Infinity
|
|
662
|
+
let rMin = Infinity
|
|
663
|
+
let rMax = -Infinity
|
|
664
|
+
for (const s of series) {
|
|
665
|
+
for (const pt of s.points ?? []) {
|
|
666
|
+
if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
|
|
667
|
+
if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
|
|
668
|
+
if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (xMin === Infinity) return { ...empty, plot }
|
|
672
|
+
// A log x axis takes its decades from the positive points only.
|
|
673
|
+
const xLog = axes.x.scale === 'log' && series.some((s) => (s.points ?? []).some((pt) => pt.x > 0))
|
|
674
|
+
const xDom = xLog
|
|
675
|
+
? (() => {
|
|
676
|
+
let lo = Infinity
|
|
677
|
+
for (const s of series) for (const pt of s.points ?? []) if (pt.x > 0 && pt.x < lo) lo = pt.x
|
|
678
|
+
const nice = niceLogScale(lo, xMax)
|
|
679
|
+
const min = axes.x.min != null && axes.x.min > 0 ? axes.x.min : nice.min
|
|
680
|
+
const max = axes.x.max != null && axes.x.max > min ? axes.x.max : Math.max(nice.max, min * 10)
|
|
681
|
+
return { min, max, step: 10, ticks: logTicks(min, max) }
|
|
682
|
+
})()
|
|
683
|
+
: axisScale(xMin, xMax, axes.x)
|
|
684
|
+
const yDom = axisScale(yMin, yMax, axes.y)
|
|
685
|
+
const hasR = rMax > rMin
|
|
686
|
+
const xOf = (v: number) => {
|
|
687
|
+
if (xLog) {
|
|
688
|
+
const t = project(v, xDom.min, xDom.max, true)
|
|
689
|
+
if (t === null) return Number.NaN
|
|
690
|
+
return round(padL + (axes.x.reversed ? 1 - t : t) * plotW)
|
|
691
|
+
}
|
|
692
|
+
let t = (v - xDom.min) / (xDom.max - xDom.min || 1)
|
|
693
|
+
if (axes.x.reversed) t = 1 - t
|
|
694
|
+
return round(padL + t * plotW)
|
|
695
|
+
}
|
|
696
|
+
const yOf = (v: number) => {
|
|
697
|
+
let t = (v - yDom.min) / (yDom.max - yDom.min || 1)
|
|
698
|
+
if (axes.y.reversed) t = 1 - t
|
|
699
|
+
return round(padT + plotH - t * plotH)
|
|
700
|
+
}
|
|
701
|
+
const rOf = (r?: number) =>
|
|
702
|
+
hasR && r != null && Number.isFinite(r)
|
|
703
|
+
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
704
|
+
: 5
|
|
705
|
+
|
|
706
|
+
const scatterPoints: ChartScatterDot[] = []
|
|
707
|
+
for (const s of series) {
|
|
708
|
+
;(s.points ?? []).forEach((pt, i) => {
|
|
709
|
+
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) return
|
|
710
|
+
if (xLog && pt.x <= 0) return
|
|
711
|
+
scatterPoints.push({
|
|
712
|
+
cx: xOf(pt.x),
|
|
713
|
+
cy: yOf(pt.y),
|
|
714
|
+
r: rOf(pt.r),
|
|
715
|
+
color: s.colors?.[i] ?? s.color,
|
|
716
|
+
label: pt.label ?? '',
|
|
717
|
+
series: s.label,
|
|
718
|
+
x: pt.x,
|
|
719
|
+
y: pt.y,
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
// ---- Regression overlays: y on x, drawn as a curve across the plot -------
|
|
724
|
+
// A scatter's `overlay` is a regression of y on the points' real x (a
|
|
725
|
+
// category chart's overlay runs on the index). The curve is sampled at
|
|
726
|
+
// 48 x positions across the domain, evenly in the axis's own space, so a
|
|
727
|
+
// log axis gets a curve that reads straight where it should.
|
|
728
|
+
const overlays: ChartLine[] = []
|
|
729
|
+
for (const s of series) {
|
|
730
|
+
if (!s.overlay) continue
|
|
731
|
+
const pts = (s.points ?? []).filter((pt) => Number.isFinite(pt.x) && Number.isFinite(pt.y) && (!xLog || pt.x > 0))
|
|
732
|
+
const fit = regressionFit(pts.map((pt) => pt.y), s.overlay, pts.map((pt) => pt.x))
|
|
733
|
+
if (!fit || !fit.equation) continue
|
|
734
|
+
const samples = 48
|
|
735
|
+
const linePts: ChartLinePoint[] = []
|
|
736
|
+
for (let i = 0; i <= samples; i += 1) {
|
|
737
|
+
const t = i / samples
|
|
738
|
+
const x = xLog ? Math.pow(10, Math.log10(xDom.min) + t * (Math.log10(xDom.max) - Math.log10(xDom.min))) : xDom.min + t * (xDom.max - xDom.min)
|
|
739
|
+
const y = fit.predict(x)
|
|
740
|
+
const inside = Number.isFinite(y) && y >= yDom.min && y <= yDom.max
|
|
741
|
+
linePts.push({ x: xOf(x), y: inside ? yOf(y) : NaN, label: fmtTick(x), value: y, defined: inside })
|
|
742
|
+
}
|
|
743
|
+
overlays.push({
|
|
744
|
+
path: buildLinePath(linePts, false),
|
|
745
|
+
areaPath: '',
|
|
746
|
+
color: s.overlayColor ?? s.color,
|
|
747
|
+
label: `${s.label} (${s.overlay})`,
|
|
748
|
+
points: linePts,
|
|
749
|
+
r2: fit.r2,
|
|
750
|
+
equation: fit.equation,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const referenceLines: ChartRefLineGeo[] = []
|
|
755
|
+
const referenceLinesV: ChartRefLineGeoV[] = []
|
|
756
|
+
for (const ref of spec.referenceLines ?? []) {
|
|
757
|
+
const v = Number(ref.value)
|
|
758
|
+
if (!Number.isFinite(v)) continue
|
|
759
|
+
if (ref.axis === 'x') {
|
|
760
|
+
referenceLinesV.push({ x: xOf(v), label: ref.label ?? fmtTick(v), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
|
|
761
|
+
} else {
|
|
762
|
+
referenceLines.push({ y: yOf(v), label: ref.label ?? formatChartValue(v, spec.valueFormat, spec), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const referenceBands: ChartRefBandGeo[] = []
|
|
766
|
+
for (const b of spec.referenceBands ?? []) {
|
|
767
|
+
const f = Number(b.from)
|
|
768
|
+
const t = Number(b.to)
|
|
769
|
+
if (!Number.isFinite(f) || !Number.isFinite(t)) continue
|
|
770
|
+
if (b.axis === 'x') {
|
|
771
|
+
const x0 = xOf(Math.min(f, t))
|
|
772
|
+
const x1 = xOf(Math.max(f, t))
|
|
773
|
+
referenceBands.push({ x: Math.min(x0, x1), y: padT, w: Math.abs(x1 - x0), h: plotH, color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'x' })
|
|
774
|
+
} else {
|
|
775
|
+
const y0 = yOf(Math.max(f, t))
|
|
776
|
+
const y1 = yOf(Math.min(f, t))
|
|
777
|
+
referenceBands.push({ x: padL, y: Math.min(y0, y1), w: plotW, h: Math.abs(y1 - y0), color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'y' })
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
...empty,
|
|
782
|
+
plot,
|
|
783
|
+
axes: {
|
|
784
|
+
y: { min: yDom.min, max: yDom.max, log: false, reversed: axes.y.reversed },
|
|
785
|
+
y2: null,
|
|
786
|
+
slot: plotW,
|
|
787
|
+
count: 0,
|
|
788
|
+
x: { type: 'number', min: xDom.min, max: xDom.max, reversed: axes.x.reversed, values: [], ...(xLog ? { log: true } : {}) },
|
|
789
|
+
},
|
|
790
|
+
grid: { x: axes.x.gridLines, y: axes.y.gridLines },
|
|
791
|
+
scatterPoints,
|
|
792
|
+
overlays,
|
|
793
|
+
referenceLines,
|
|
794
|
+
referenceLinesV,
|
|
795
|
+
referenceBands,
|
|
796
|
+
yTicks: yDom.ticks.map((value, i) => ({ value, y: yOf(value), label: axes.y.labels ? axisTickLabel(value, i, axes.y, spec, formatChartValue) : '' })),
|
|
797
|
+
xTicks: xDom.ticks.map((value, i) => ({
|
|
798
|
+
label: axes.x.labels ? (axes.x.formatter ? axes.x.formatter(value, i) : axes.x.format ? formatChartValue(value, axes.x.format, spec) : fmtTick(value)) : '',
|
|
799
|
+
x: xOf(value),
|
|
800
|
+
})),
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---- Horizontal bars ------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* @internal Lay out the horizontal bars family. Called by buildChart.
|
|
808
|
+
* Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
809
|
+
*/
|
|
810
|
+
export function layoutHorizontalBars(ctx: LayoutCtx): ChartGeometry {
|
|
811
|
+
const { spec, series, empty, width, height, stacked, frame, axes } = ctx
|
|
812
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
813
|
+
const padL = axes.x.labels
|
|
814
|
+
? Math.min(150, 18 + maxLabel * 6.4) + (axes.y.title ? 16 : 0)
|
|
815
|
+
: 12 + (axes.y.title ? 16 : 0)
|
|
816
|
+
const padR = 16
|
|
817
|
+
const padT = 8 + frame.top
|
|
818
|
+
const padB = (axes.y.labels ? 26 : 8) + (axes.x.title ? 16 : 0) + frame.bottom
|
|
819
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
820
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
821
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
822
|
+
|
|
823
|
+
const refs = (spec.referenceLines ?? [])
|
|
824
|
+
.filter((r) => r.axis !== 'x')
|
|
825
|
+
.map((r) => Number(r.value))
|
|
826
|
+
.concat((spec.referenceBands ?? []).filter((b) => b.axis !== 'x').flatMap((b) => [Number(b.from), Number(b.to)]))
|
|
827
|
+
const isLog = axes.y.scale === 'log'
|
|
828
|
+
const dom = spec.stacked100 ? axisScale(0, 100) : axisDomain(series, spec.categories, stacked, refs, isLog, axes.y)
|
|
829
|
+
const xOf = (v: number) => {
|
|
830
|
+
let t = project(v, dom.min, dom.max, isLog)
|
|
831
|
+
if (t === null) return Number.NaN
|
|
832
|
+
if (axes.y.reversed) t = 1 - t
|
|
833
|
+
return round(padL + t * plotW)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const n = spec.categories.length
|
|
837
|
+
const slot = plotH / Math.max(1, n)
|
|
838
|
+
const groupPad = slot * 0.2
|
|
839
|
+
const inner = slot - groupPad
|
|
840
|
+
const bandTop = (i: number) => padT + slot * (axes.x.reversed ? n - 1 - i : i) + groupPad / 2
|
|
841
|
+
const xBase = xOf(isLog ? dom.min : Math.min(Math.max(0, dom.min), dom.max))
|
|
842
|
+
|
|
843
|
+
const bars: ChartBar[] = []
|
|
844
|
+
if (stacked) {
|
|
845
|
+
const totals = spec.stacked100
|
|
846
|
+
? spec.categories.map(
|
|
847
|
+
(_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
848
|
+
)
|
|
849
|
+
: null
|
|
850
|
+
const pos = new Array(n).fill(0)
|
|
851
|
+
const neg = new Array(n).fill(0)
|
|
852
|
+
for (const s of series) {
|
|
853
|
+
s.values.forEach((v, i) => {
|
|
854
|
+
if (!Number.isFinite(v)) return
|
|
855
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
856
|
+
let xL: number
|
|
857
|
+
let xR: number
|
|
858
|
+
if (vp >= 0) {
|
|
859
|
+
xL = xOf(pos[i])
|
|
860
|
+
xR = xOf(pos[i] + vp)
|
|
861
|
+
pos[i] += vp
|
|
862
|
+
} else {
|
|
863
|
+
xL = xOf(neg[i] + vp)
|
|
864
|
+
xR = xOf(neg[i])
|
|
865
|
+
neg[i] += vp
|
|
866
|
+
}
|
|
867
|
+
if (!Number.isFinite(xL) || !Number.isFinite(xR)) return
|
|
868
|
+
bars.push({
|
|
869
|
+
x: Math.min(xL, xR),
|
|
870
|
+
y: round(bandTop(i)),
|
|
871
|
+
w: round(Math.abs(xR - xL)),
|
|
872
|
+
h: round(Math.max(1, inner)),
|
|
873
|
+
color: s.colors?.[i] ?? s.color,
|
|
874
|
+
label: spec.categories[i] ?? String(i),
|
|
875
|
+
series: s.label,
|
|
876
|
+
value: v,
|
|
877
|
+
index: i,
|
|
878
|
+
...(s.opacity != null ? { opacity: s.opacity } : {}),
|
|
879
|
+
})
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
} else {
|
|
883
|
+
const barH = inner / series.length
|
|
884
|
+
series.forEach((s, bi) => {
|
|
885
|
+
s.values.forEach((v, i) => {
|
|
886
|
+
if (!Number.isFinite(v)) return
|
|
887
|
+
if (isLog && v <= 0) return
|
|
888
|
+
const xV = xOf(v)
|
|
889
|
+
if (!Number.isFinite(xV)) return
|
|
890
|
+
bars.push({
|
|
891
|
+
x: Math.min(xV, xBase),
|
|
892
|
+
y: round(bandTop(i) + barH * bi),
|
|
893
|
+
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
894
|
+
h: round(Math.max(1, barH - 1)),
|
|
895
|
+
color: s.colors?.[i] ?? s.color,
|
|
896
|
+
label: spec.categories[i] ?? String(i),
|
|
897
|
+
series: s.label,
|
|
898
|
+
value: v,
|
|
899
|
+
index: i,
|
|
900
|
+
...(s.opacity != null ? { opacity: s.opacity } : {}),
|
|
901
|
+
})
|
|
902
|
+
})
|
|
903
|
+
})
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value, i) => ({
|
|
907
|
+
label: !axes.y.labels
|
|
908
|
+
? ''
|
|
909
|
+
: spec.stacked100
|
|
910
|
+
? `${fmtTick(value)}%`
|
|
911
|
+
: axisTickLabel(value, i, axes.y, spec, formatChartValue),
|
|
912
|
+
x: xOf(value),
|
|
913
|
+
}))
|
|
914
|
+
const catTicks: ChartAxisTick[] = axes.x.labels
|
|
915
|
+
? spec.categories.map((label, i) => ({ value: i, y: round(bandTop(i) + inner / 2), label }))
|
|
916
|
+
: []
|
|
917
|
+
const referenceLinesV: ChartRefLineGeoV[] = []
|
|
918
|
+
const referenceLines: ChartRefLineGeo[] = []
|
|
919
|
+
for (const ref of spec.referenceLines ?? []) {
|
|
920
|
+
if (ref.axis === 'x') {
|
|
921
|
+
// A category reference on a horizontal chart is a horizontal line.
|
|
922
|
+
const i = typeof ref.value === 'number' ? ref.value : spec.categories.indexOf(ref.value)
|
|
923
|
+
if (i < 0 || !Number.isFinite(i)) continue
|
|
924
|
+
referenceLines.push({ y: round(bandTop(i) + inner / 2), label: ref.label ?? String(ref.value), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
|
|
925
|
+
continue
|
|
926
|
+
}
|
|
927
|
+
const v = Number(ref.value)
|
|
928
|
+
const x = xOf(v)
|
|
929
|
+
if (!Number.isFinite(x)) continue
|
|
930
|
+
referenceLinesV.push({ x, label: ref.label ?? formatChartValue(v, spec.valueFormat, spec), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
|
|
931
|
+
}
|
|
932
|
+
const referenceBands: ChartRefBandGeo[] = []
|
|
933
|
+
for (const b of spec.referenceBands ?? []) {
|
|
934
|
+
if (b.axis === 'x') {
|
|
935
|
+
const i0 = typeof b.from === 'number' ? b.from : spec.categories.indexOf(b.from)
|
|
936
|
+
const i1 = typeof b.to === 'number' ? b.to : spec.categories.indexOf(b.to)
|
|
937
|
+
if (i0 < 0 || i1 < 0) continue
|
|
938
|
+
const y0 = bandTop(Math.min(i0, i1)) - groupPad / 2
|
|
939
|
+
const y1 = bandTop(Math.max(i0, i1)) + inner + groupPad / 2
|
|
940
|
+
referenceBands.push({ x: padL, y: round(Math.min(y0, y1)), w: plotW, h: round(Math.abs(y1 - y0)), color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'y' })
|
|
941
|
+
continue
|
|
942
|
+
}
|
|
943
|
+
const x0 = xOf(Number(b.from))
|
|
944
|
+
const x1 = xOf(Number(b.to))
|
|
945
|
+
if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue
|
|
946
|
+
referenceBands.push({ x: Math.min(x0, x1), y: padT, w: Math.abs(x1 - x0), h: plotH, color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'x' })
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
...empty,
|
|
951
|
+
plot,
|
|
952
|
+
axes: {
|
|
953
|
+
y: { min: dom.min, max: dom.max, log: isLog, reversed: axes.y.reversed },
|
|
954
|
+
y2: null,
|
|
955
|
+
slot,
|
|
956
|
+
count: n,
|
|
957
|
+
xReversed: axes.x.reversed,
|
|
958
|
+
labels: spec.categories,
|
|
959
|
+
},
|
|
960
|
+
grid: { x: axes.y.gridLines, y: axes.x.gridLines },
|
|
961
|
+
bars,
|
|
962
|
+
orientation: 'horizontal',
|
|
963
|
+
valueTicks,
|
|
964
|
+
catTicks,
|
|
965
|
+
referenceLines,
|
|
966
|
+
referenceLinesV,
|
|
967
|
+
referenceBands,
|
|
968
|
+
xLabelRotated: false,
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ---- Cartesian core (bar / line / area, combo, dual axis) ------------------
|
|
973
|
+
|
|
974
|
+
/** @internal Lay out the cartesian family. Called by buildChart. */
|
|
975
|
+
export function layoutCartesian(ctx: LayoutCtx): ChartGeometry {
|
|
976
|
+
const { spec, series, empty, width, height, stacked, frame, axes } = ctx
|
|
977
|
+
const leftSeries = series.filter((s) => s.axis === 'left')
|
|
978
|
+
const rightSeries = series.filter((s) => s.axis === 'right')
|
|
979
|
+
const hasRightAxis = rightSeries.length > 0
|
|
980
|
+
|
|
981
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
982
|
+
// Grouped (nested) category axis: valid only when the spans cover every leaf.
|
|
983
|
+
const validGroups =
|
|
984
|
+
spec.categoryGroups &&
|
|
985
|
+
spec.categoryGroups.length > 0 &&
|
|
986
|
+
axes.x.type !== 'time' &&
|
|
987
|
+
spec.orientation !== 'horizontal' &&
|
|
988
|
+
spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
|
|
989
|
+
? spec.categoryGroups
|
|
990
|
+
: null
|
|
991
|
+
const groupTierH = validGroups ? 18 : 0
|
|
992
|
+
const padL = axes.y.width ?? (axes.y.labels ? 48 : 12) + (axes.y.title ? 16 : 0)
|
|
993
|
+
// End labels need their own gutter past the right axis: the longest name
|
|
994
|
+
// at about 6.2px a character, capped so a long label cannot eat the plot.
|
|
995
|
+
const endLabelText = spec.seriesLabels ? seriesLabelTexts(spec, series) : null
|
|
996
|
+
const endLabelPad = endLabelText
|
|
997
|
+
? Math.min(Math.floor(width / 3), 8 + Math.max(0, ...endLabelText.values()) * 6.2)
|
|
998
|
+
: 0
|
|
999
|
+
const padR = (hasRightAxis ? (axes.y2.width ?? (axes.y2.labels ? 48 : 12)) : 12) + (axes.y2.title ? 16 : 0) + endLabelPad
|
|
1000
|
+
const padT = 10 + frame.top
|
|
1001
|
+
// The x layout needs the plot width, and the bottom pad needs the x layout's
|
|
1002
|
+
// rotation. Width does not depend on the pad, so lay x out first at the
|
|
1003
|
+
// final width and settle the pad after.
|
|
1004
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1005
|
+
// Labels tilt when the widest one does not fit its slot, at about 6.2px a
|
|
1006
|
+
// character plus a little air. Judged by count and length alone, twelve
|
|
1007
|
+
// three-letter months stood tilted across a thousand-pixel chart.
|
|
1008
|
+
const autoRotated = maxLabel * 6.2 + 8 > plotW / Math.max(1, spec.categories.length)
|
|
1009
|
+
const xl = layoutX(spec, axes.x, padL, plotW, autoRotated)
|
|
1010
|
+
const padB = xLabelPad(spec, axes.x, xl) + (axes.x.title ? 16 : 0) + groupTierH + frame.bottom
|
|
1011
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1012
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1013
|
+
|
|
1014
|
+
const yRefs = (spec.referenceLines ?? []).filter((r) => r.axis !== 'x')
|
|
1015
|
+
const yBands = (spec.referenceBands ?? []).filter((b) => b.axis !== 'x')
|
|
1016
|
+
const refsLeft = yRefs.filter((r) => r.axis !== 'right').map((r) => Number(r.value))
|
|
1017
|
+
.concat(yBands.filter((b) => b.axis !== 'right').flatMap((b) => [Number(b.from), Number(b.to)]))
|
|
1018
|
+
const refsRight = yRefs.filter((r) => r.axis === 'right').map((r) => Number(r.value))
|
|
1019
|
+
.concat(yBands.filter((b) => b.axis === 'right').flatMap((b) => [Number(b.from), Number(b.to)]))
|
|
1020
|
+
const leftLog = axes.y.scale === 'log'
|
|
1021
|
+
const rightLog = axes.y2.scale === 'log'
|
|
1022
|
+
// A stream graph's areas stack on a wiggle / silhouette baseline rather than
|
|
1023
|
+
// the axis, and the baseline is part of the domain: the layers run from the
|
|
1024
|
+
// lowest baseline to the highest baseline-plus-total.
|
|
1025
|
+
// An area belongs to a pile: its `stack` name, the default pile when the
|
|
1026
|
+
// chart is stacked, or none. Piles are per axis, so a dual-axis stack lines
|
|
1027
|
+
// up to its own scale; the key below is what every per-pile map hangs on.
|
|
1028
|
+
const areaPile = (s: ResolvedSeries): string | null =>
|
|
1029
|
+
s.kind !== 'area' ? null : s.stack ? `${s.axis}\u0000${s.stack}` : stacked ? s.axis : null
|
|
1030
|
+
const areaPiles = [...new Set(series.map(areaPile).filter((k): k is string => k !== null))]
|
|
1031
|
+
const streamOffset = spec.stackOffset && spec.stackOffset !== 'zero' && !spec.stacked100 ? spec.stackOffset : null
|
|
1032
|
+
const streamBase = new Map<string, number[]>()
|
|
1033
|
+
const streamExtra: Record<'left' | 'right', number[]> = { left: [], right: [] }
|
|
1034
|
+
if (streamOffset) {
|
|
1035
|
+
for (const pile of areaPiles) {
|
|
1036
|
+
const members = series.filter((s) => areaPile(s) === pile)
|
|
1037
|
+
const layers = members.map((s) => s.values)
|
|
1038
|
+
const base = streamBaseline(layers, streamOffset)
|
|
1039
|
+
streamBase.set(pile, base)
|
|
1040
|
+
const axis = members[0]!.axis
|
|
1041
|
+
base.forEach((b, j) => {
|
|
1042
|
+
let total = 0
|
|
1043
|
+
for (const l of layers) total += Number.isFinite(l[j]) ? l[j]! : 0
|
|
1044
|
+
streamExtra[axis].push(b, b + total)
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const leftDom = spec.stacked100
|
|
1049
|
+
? axisScale(0, 100)
|
|
1050
|
+
: axisDomain(leftSeries, spec.categories, stacked, refsLeft.concat(streamExtra.left), leftLog, axes.y)
|
|
1051
|
+
const rightDom = hasRightAxis
|
|
1052
|
+
? spec.stacked100
|
|
1053
|
+
? axisScale(0, 100)
|
|
1054
|
+
: axisDomain(rightSeries, spec.categories, stacked, refsRight.concat(streamExtra.right), rightLog, axes.y2)
|
|
1055
|
+
: null
|
|
1056
|
+
|
|
1057
|
+
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
1058
|
+
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
1059
|
+
const yOf = (dom: NiceScale, v: number, isLog = false, reversed = false) => {
|
|
1060
|
+
let t = project(v, dom.min, dom.max, isLog)
|
|
1061
|
+
if (t === null) return NaN
|
|
1062
|
+
if (reversed) t = 1 - t
|
|
1063
|
+
return round(padT + plotH - t * plotH)
|
|
1064
|
+
}
|
|
1065
|
+
const yLeft = (v: number) => yOf(leftDom, v, leftLog, axes.y.reversed)
|
|
1066
|
+
const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog, axes.y2.reversed)
|
|
1067
|
+
const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
|
|
1068
|
+
const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
|
|
1069
|
+
const revOf = (s: ResolvedSeries) => (s.axis === 'right' ? axes.y2.reversed : axes.y.reversed)
|
|
1070
|
+
const ySer = (s: ResolvedSeries, v: number) => yOf(domOf(s), v, isLogOf(s), revOf(s))
|
|
1071
|
+
|
|
1072
|
+
const n = spec.categories.length
|
|
1073
|
+
const { slot, xCenter, ticks: xTicks } = xl
|
|
1074
|
+
|
|
1075
|
+
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1076
|
+
const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
|
|
1077
|
+
if (validGroups && !xl.values) {
|
|
1078
|
+
let start = 0
|
|
1079
|
+
for (const g of validGroups) {
|
|
1080
|
+
const x0 = round(padL + slot * start)
|
|
1081
|
+
const x1 = round(padL + slot * (start + g.span))
|
|
1082
|
+
categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
|
|
1083
|
+
start += g.span
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Everything that shares the category slot the way grouped bars do.
|
|
1088
|
+
const barLike = series.filter((s) => s.kind === 'bar' || s.kind === 'range-bar' || s.kind === 'lollipop' || s.kind === 'dumbbell')
|
|
1089
|
+
const bars: ChartBar[] = []
|
|
1090
|
+
const stems: ChartStem[] = []
|
|
1091
|
+
if (barLike.length) {
|
|
1092
|
+
// A histogram's bins touch: no gap between groups and none inside.
|
|
1093
|
+
const noGap = spec.type === 'histogram'
|
|
1094
|
+
const groupPad = noGap ? 0 : xl.barSlot * 0.2
|
|
1095
|
+
const inner = xl.barSlot - groupPad
|
|
1096
|
+
const x0 = (i: number) => xl.barX0(i) + groupPad / 2
|
|
1097
|
+
// The category slot is split into SLOTS side by side: a stack (every bar
|
|
1098
|
+
// series naming it, or every bar series when `stacked`) is one slot, and
|
|
1099
|
+
// a lone series, a range bar or a stem is its own. So `stack: 'plan'` on
|
|
1100
|
+
// two series and `stack: 'actual'` on two more gives two stacks per
|
|
1101
|
+
// category; a stacked chart with a lollipop gives the stack and the stem
|
|
1102
|
+
// their own room instead of one over the other.
|
|
1103
|
+
type Slot = { key: string; members: ResolvedSeries[]; stacked: boolean }
|
|
1104
|
+
const slots: Slot[] = []
|
|
1105
|
+
const stackSlots = new Map<string, Slot>()
|
|
1106
|
+
for (const s of barLike) {
|
|
1107
|
+
const key = s.kind === 'bar' ? (s.stack ?? (stacked ? '' : null)) : null
|
|
1108
|
+
if (key === null) { slots.push({ key: s.label, members: [s], stacked: false }); continue }
|
|
1109
|
+
let slot = stackSlots.get(key)
|
|
1110
|
+
if (!slot) { slot = { key, members: [], stacked: true }; stackSlots.set(key, slot); slots.push(slot) }
|
|
1111
|
+
slot.members.push(s)
|
|
1112
|
+
}
|
|
1113
|
+
const slotW = inner / slots.length
|
|
1114
|
+
slots.forEach((slot, si) => {
|
|
1115
|
+
if (!slot.stacked) return
|
|
1116
|
+
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
1117
|
+
for (const axis of ['left', 'right'] as const) {
|
|
1118
|
+
const axisBars = slot.members.filter((s) => s.axis === axis)
|
|
1119
|
+
if (!axisBars.length) continue
|
|
1120
|
+
const yA = axis === 'right' ? yRight : yLeft
|
|
1121
|
+
// 100% mode normalizes each category to its absolute total.
|
|
1122
|
+
const totals = spec.stacked100
|
|
1123
|
+
? spec.categories.map(
|
|
1124
|
+
(_, i) =>
|
|
1125
|
+
axisBars.reduce(
|
|
1126
|
+
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
1127
|
+
0,
|
|
1128
|
+
) || 1,
|
|
1129
|
+
)
|
|
1130
|
+
: null
|
|
1131
|
+
const pos = new Array(n).fill(0)
|
|
1132
|
+
const neg = new Array(n).fill(0)
|
|
1133
|
+
for (const s of axisBars) {
|
|
1134
|
+
s.values.forEach((v, i) => {
|
|
1135
|
+
if (!Number.isFinite(v)) return
|
|
1136
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
1137
|
+
let yTop: number
|
|
1138
|
+
let yBot: number
|
|
1139
|
+
if (vp >= 0) {
|
|
1140
|
+
yTop = yA(pos[i] + vp)
|
|
1141
|
+
yBot = yA(pos[i])
|
|
1142
|
+
pos[i] += vp
|
|
1143
|
+
} else {
|
|
1144
|
+
yTop = yA(neg[i])
|
|
1145
|
+
yBot = yA(neg[i] + vp)
|
|
1146
|
+
neg[i] += vp
|
|
1147
|
+
}
|
|
1148
|
+
if (!Number.isFinite(yTop) || !Number.isFinite(yBot)) return
|
|
1149
|
+
bars.push({
|
|
1150
|
+
x: round(x0(i) + slotW * si),
|
|
1151
|
+
y: Math.min(yTop, yBot),
|
|
1152
|
+
w: round(Math.max(1, slots.length > 1 ? slotW - (noGap ? 0 : 1) : slotW)),
|
|
1153
|
+
h: round(Math.abs(yBot - yTop)),
|
|
1154
|
+
color: s.colors?.[i] ?? s.color,
|
|
1155
|
+
label: spec.categories[i] ?? String(i),
|
|
1156
|
+
series: s.label,
|
|
1157
|
+
value: v,
|
|
1158
|
+
index: i,
|
|
1159
|
+
...(s.opacity != null ? { opacity: s.opacity } : {}),
|
|
1160
|
+
})
|
|
1161
|
+
})
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
})
|
|
1165
|
+
// Lone series, range bars and stems: one slot each, side by side.
|
|
1166
|
+
const lone = slots.map((slot, si) => ({ slot, si })).filter(({ slot }) => !slot.stacked)
|
|
1167
|
+
if (lone.length) {
|
|
1168
|
+
const barW = slotW
|
|
1169
|
+
lone.forEach(({ slot, si }) => {
|
|
1170
|
+
const s = slot.members[0]!
|
|
1171
|
+
const bi = si
|
|
1172
|
+
const dom = domOf(s)
|
|
1173
|
+
const log = isLogOf(s)
|
|
1174
|
+
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
1175
|
+
// than from 0, since 0 is invalid in log space.
|
|
1176
|
+
const base = log ? ySer(s, dom.min) : ySer(s, Math.min(Math.max(0, dom.min), dom.max))
|
|
1177
|
+
const dotR = round(Math.min(5, Math.max(2.5, barW * 0.3)))
|
|
1178
|
+
s.values.forEach((v, i) => {
|
|
1179
|
+
if (!Number.isFinite(v)) return
|
|
1180
|
+
if (log && v <= 0) return
|
|
1181
|
+
const x = x0(i) + barW * bi
|
|
1182
|
+
const yV = ySer(s, v)
|
|
1183
|
+
if (!Number.isFinite(yV)) return
|
|
1184
|
+
const color = s.colors?.[i] ?? s.color
|
|
1185
|
+
const label = spec.categories[i] ?? String(i)
|
|
1186
|
+
if (s.kind === 'lollipop') {
|
|
1187
|
+
stems.push({ xCenter: round(x + barW / 2), y0: base, y1: yV, r: dotR, color, label, series: s.label, value: v, dumbbell: false })
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
if (s.kind === 'dumbbell' || s.kind === 'range-bar') {
|
|
1191
|
+
const lo = s.lowValues?.[i]
|
|
1192
|
+
if (!Number.isFinite(lo) || (log && (lo as number) <= 0)) return
|
|
1193
|
+
const yLo = ySer(s, lo as number)
|
|
1194
|
+
if (!Number.isFinite(yLo)) return
|
|
1195
|
+
if (s.kind === 'dumbbell') {
|
|
1196
|
+
stems.push({ xCenter: round(x + barW / 2), y0: yLo, y1: yV, r: dotR, color, color2: s.overlayColor ?? color, label, series: s.label, value: v, value2: lo as number, dumbbell: true })
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
bars.push({
|
|
1200
|
+
x: round(x),
|
|
1201
|
+
y: Math.min(yV, yLo),
|
|
1202
|
+
w: round(Math.max(1, barW - (noGap ? 0 : 1))),
|
|
1203
|
+
h: round(Math.max(1, Math.abs(yV - yLo))),
|
|
1204
|
+
color,
|
|
1205
|
+
label,
|
|
1206
|
+
series: s.label,
|
|
1207
|
+
value: v,
|
|
1208
|
+
index: i,
|
|
1209
|
+
lo: lo as number,
|
|
1210
|
+
...(s.opacity != null ? { opacity: s.opacity } : {}),
|
|
1211
|
+
})
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1214
|
+
bars.push({
|
|
1215
|
+
x: round(x),
|
|
1216
|
+
y: Math.min(yV, base),
|
|
1217
|
+
w: round(Math.max(1, barW - (noGap ? 0 : 1))),
|
|
1218
|
+
h: round(Math.max(1, Math.abs(yV - base))),
|
|
1219
|
+
color,
|
|
1220
|
+
label,
|
|
1221
|
+
series: s.label,
|
|
1222
|
+
value: v,
|
|
1223
|
+
index: i,
|
|
1224
|
+
...(s.opacity != null ? { opacity: s.opacity } : {}),
|
|
1225
|
+
})
|
|
1226
|
+
})
|
|
1227
|
+
})
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
|
|
1232
|
+
// the renderer paints them, so there is no second geometry pass.
|
|
1233
|
+
const candleSeries = series.filter((s) => s.kind === 'candle')
|
|
1234
|
+
const candles: ChartCandle[] = []
|
|
1235
|
+
if (candleSeries.length) {
|
|
1236
|
+
const upColor = spec.candleColors?.up ?? '#16a34a'
|
|
1237
|
+
const downColor = spec.candleColors?.down ?? '#ef4444'
|
|
1238
|
+
// Share the slot when two instruments are charted together, the same way
|
|
1239
|
+
// grouped bars do.
|
|
1240
|
+
const bodyW = Math.max(1, (xl.barSlot * 0.7) / candleSeries.length)
|
|
1241
|
+
const style = spec.candleStyle ?? 'classic'
|
|
1242
|
+
candleSeries.forEach((s, si) => {
|
|
1243
|
+
const log = isLogOf(s)
|
|
1244
|
+
const raw = s.ohlc ?? []
|
|
1245
|
+
const bars: ReadonlyArray<OhlcBar | null> = style === 'heikin-ashi' ? heikinAshi(raw) : raw
|
|
1246
|
+
let prevClose: number | null = null
|
|
1247
|
+
bars.forEach((k, i) => {
|
|
1248
|
+
if (!k) { prevClose = null; return }
|
|
1249
|
+
if (![k.o, k.h, k.l, k.c].every(Number.isFinite)) return
|
|
1250
|
+
// A log price axis is genuinely used for long histories, and a
|
|
1251
|
+
// non-positive price has no place on one.
|
|
1252
|
+
if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0)) return
|
|
1253
|
+
const centre = xl.barX0(i) + xl.barSlot / 2
|
|
1254
|
+
const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si
|
|
1255
|
+
const yOpen = ySer(s, k.o)
|
|
1256
|
+
const yClose = ySer(s, k.c)
|
|
1257
|
+
// Classic: colour and fill both follow close vs open. Hollow: colour
|
|
1258
|
+
// follows close vs the PREVIOUS close, fill follows close vs open.
|
|
1259
|
+
const bodyUp = k.c >= k.o
|
|
1260
|
+
const up = style === 'hollow' && prevClose != null ? k.c >= prevClose : bodyUp
|
|
1261
|
+
const hollow = style === 'hollow' ? bodyUp : up
|
|
1262
|
+
prevClose = k.c
|
|
1263
|
+
candles.push({
|
|
1264
|
+
x: round(x),
|
|
1265
|
+
w: round(bodyW),
|
|
1266
|
+
xCenter: round(x + bodyW / 2),
|
|
1267
|
+
yOpen,
|
|
1268
|
+
yClose,
|
|
1269
|
+
yHigh: ySer(s, k.h),
|
|
1270
|
+
yLow: ySer(s, k.l),
|
|
1271
|
+
bodyY: Math.min(yOpen, yClose),
|
|
1272
|
+
// A doji closes where it opened; keep it visible as a 1px line
|
|
1273
|
+
// rather than a zero-height rect that paints nothing.
|
|
1274
|
+
bodyH: Math.max(1, Math.abs(yClose - yOpen)),
|
|
1275
|
+
up,
|
|
1276
|
+
hollow,
|
|
1277
|
+
color: up ? upColor : downColor,
|
|
1278
|
+
label: spec.categories[i] ?? String(i),
|
|
1279
|
+
series: s.label,
|
|
1280
|
+
o: k.o,
|
|
1281
|
+
h: k.h,
|
|
1282
|
+
l: k.l,
|
|
1283
|
+
c: k.c,
|
|
1284
|
+
})
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Box plots. Same slot-sharing as grouped bars and candles, so several
|
|
1290
|
+
// samples can sit side by side under one category.
|
|
1291
|
+
const boxSeries = series.filter((s) => s.kind === 'box')
|
|
1292
|
+
const boxes: ChartBox[] = []
|
|
1293
|
+
if (boxSeries.length) {
|
|
1294
|
+
const boxW = Math.max(1, (xl.barSlot * 0.6) / boxSeries.length)
|
|
1295
|
+
boxSeries.forEach((s, si) => {
|
|
1296
|
+
const log = isLogOf(s)
|
|
1297
|
+
;(s.boxes ?? []).forEach((b, i) => {
|
|
1298
|
+
if (!b) return
|
|
1299
|
+
if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite)) return
|
|
1300
|
+
if (log && b.min <= 0) return
|
|
1301
|
+
const centre = xl.barX0(i) + xl.barSlot / 2
|
|
1302
|
+
const x = centre - (boxW * boxSeries.length) / 2 + boxW * si
|
|
1303
|
+
const yQ1 = ySer(s, b.q1)
|
|
1304
|
+
const yQ3 = ySer(s, b.q3)
|
|
1305
|
+
boxes.push({
|
|
1306
|
+
x: round(x),
|
|
1307
|
+
w: round(boxW),
|
|
1308
|
+
xCenter: round(x + boxW / 2),
|
|
1309
|
+
yMin: ySer(s, b.min),
|
|
1310
|
+
yQ1,
|
|
1311
|
+
yMedian: ySer(s, b.median),
|
|
1312
|
+
yQ3,
|
|
1313
|
+
yMax: ySer(s, b.max),
|
|
1314
|
+
boxY: Math.min(yQ1, yQ3),
|
|
1315
|
+
// A sample with no spread would otherwise paint nothing at all.
|
|
1316
|
+
boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
|
|
1317
|
+
outliers: (b.outliers ?? [])
|
|
1318
|
+
.filter((o) => Number.isFinite(o) && (!log || o > 0))
|
|
1319
|
+
.map((o) => ({ y: ySer(s, o), value: o })),
|
|
1320
|
+
color: s.color,
|
|
1321
|
+
label: spec.categories[i] ?? String(i),
|
|
1322
|
+
series: s.label,
|
|
1323
|
+
min: b.min,
|
|
1324
|
+
q1: b.q1,
|
|
1325
|
+
median: b.median,
|
|
1326
|
+
q3: b.q3,
|
|
1327
|
+
max: b.max,
|
|
1328
|
+
})
|
|
1329
|
+
})
|
|
1330
|
+
})
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Error bars. Not a mark of their own: they annotate whatever the series
|
|
1334
|
+
// already draws, so this runs over every series carrying `errors` regardless
|
|
1335
|
+
// of kind, and the geometry sits in its own array so no existing loop changes.
|
|
1336
|
+
const errorBars: ChartErrorBar[] = []
|
|
1337
|
+
for (const s of series) {
|
|
1338
|
+
if (!s.errors) continue
|
|
1339
|
+
const log = isLogOf(s)
|
|
1340
|
+
s.errors.forEach((e, i) => {
|
|
1341
|
+
const v = s.values[i]
|
|
1342
|
+
if (!Number.isFinite(v)) return
|
|
1343
|
+
const span = errorSpan(e, v!)
|
|
1344
|
+
if (!span) return
|
|
1345
|
+
if (log && span.lo <= 0) return
|
|
1346
|
+
errorBars.push({
|
|
1347
|
+
xCenter: round(xl.barX0(i) + xl.barSlot / 2),
|
|
1348
|
+
yLo: ySer(s, span.lo),
|
|
1349
|
+
yHi: ySer(s, span.hi),
|
|
1350
|
+
cap: round(Math.min(6, xl.barSlot * 0.15)),
|
|
1351
|
+
color: s.color,
|
|
1352
|
+
label: spec.categories[i] ?? String(i),
|
|
1353
|
+
series: s.label,
|
|
1354
|
+
lo: span.lo,
|
|
1355
|
+
hi: span.hi,
|
|
1356
|
+
})
|
|
1357
|
+
})
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Lines / areas. Stacked areas accumulate per pile; others fill to baseline.
|
|
1361
|
+
const lines: ChartLine[] = []
|
|
1362
|
+
const areaCum = new Map<string, number[]>()
|
|
1363
|
+
// 100% mode: per-pile per-category totals to normalize stacked areas to 100.
|
|
1364
|
+
const areaTotals = new Map<string, number[]>()
|
|
1365
|
+
const streamSeeded = new Set<string>()
|
|
1366
|
+
for (const pile of areaPiles) {
|
|
1367
|
+
areaCum.set(pile, new Array(n).fill(0))
|
|
1368
|
+
if (spec.stacked100) {
|
|
1369
|
+
const members = series.filter((s) => areaPile(s) === pile)
|
|
1370
|
+
areaTotals.set(
|
|
1371
|
+
pile,
|
|
1372
|
+
spec.categories.map(
|
|
1373
|
+
(_, i) =>
|
|
1374
|
+
members.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
1375
|
+
),
|
|
1376
|
+
)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
for (const s of series) {
|
|
1380
|
+
// Bars, candles and boxes draw their own marks. Candles and boxes
|
|
1381
|
+
// especially: `values` holds their closes / medians so that tooltips, CSV
|
|
1382
|
+
// and overlays work, and without this guard that same array was ALSO drawn
|
|
1383
|
+
// as a line, laying a dotted close-line straight over every candle. Boxes
|
|
1384
|
+
// would do exactly the same thing through the median.
|
|
1385
|
+
if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box') continue
|
|
1386
|
+
if (s.kind === 'range-bar' || s.kind === 'lollipop' || s.kind === 'dumbbell') continue
|
|
1387
|
+
const dom = domOf(s)
|
|
1388
|
+
const log = isLogOf(s)
|
|
1389
|
+
const yA = (v: number) => ySer(s, v)
|
|
1390
|
+
const pile = areaPile(s)
|
|
1391
|
+
const isStackedArea = pile !== null
|
|
1392
|
+
const px = (i: number) => xCenter(i)
|
|
1393
|
+
let pts: ChartLinePoint[]
|
|
1394
|
+
let baselinePts: XY[] | null = null
|
|
1395
|
+
const pointAt = (i: number, v: number, y: number, defined: boolean): ChartLinePoint => {
|
|
1396
|
+
const p: ChartLinePoint = { x: px(i), y, label: spec.categories[i] ?? String(i), value: v, defined }
|
|
1397
|
+
const m = markerAt(s, i)
|
|
1398
|
+
if (m) p.marker = m
|
|
1399
|
+
return p
|
|
1400
|
+
}
|
|
1401
|
+
if (isStackedArea) {
|
|
1402
|
+
// Stacked areas treat a gap as 0 so the stack stays continuous. A stream
|
|
1403
|
+
// starts its first layer on the wiggle baseline instead of the axis.
|
|
1404
|
+
const cum = areaCum.get(pile!)!
|
|
1405
|
+
const sb = streamBase.get(pile!)
|
|
1406
|
+
if (sb && !streamSeeded.has(pile!)) {
|
|
1407
|
+
for (let j = 0; j < n; j += 1) cum[j] = sb[j] ?? 0
|
|
1408
|
+
streamSeeded.add(pile!)
|
|
1409
|
+
}
|
|
1410
|
+
const prev = cum.slice()
|
|
1411
|
+
const totals = areaTotals.get(pile!)
|
|
1412
|
+
pts = s.values.map((v, i) => {
|
|
1413
|
+
const vv = Number.isFinite(v) ? v : 0
|
|
1414
|
+
// 100% mode positions by share of the category total; value stays original.
|
|
1415
|
+
const norm = totals ? (vv / totals[i]!) * 100 : vv
|
|
1416
|
+
const c = (cum[i] ?? 0) + norm
|
|
1417
|
+
cum[i] = c
|
|
1418
|
+
return pointAt(i, v, yA(c), Number.isFinite(v))
|
|
1419
|
+
})
|
|
1420
|
+
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
|
|
1421
|
+
} else {
|
|
1422
|
+
pts = s.values.map((v, i) => {
|
|
1423
|
+
const ok = Number.isFinite(v) && Number.isFinite(yA(v))
|
|
1424
|
+
return pointAt(i, v, ok ? yA(v) : NaN, ok)
|
|
1425
|
+
})
|
|
1426
|
+
}
|
|
1427
|
+
// Build the line - smoothed via monotone cubic when requested, else
|
|
1428
|
+
// straight polylines. Either way, gaps break the path cleanly (unless the
|
|
1429
|
+
// series asks to bridge them). A scatter kind draws markers only.
|
|
1430
|
+
const smooth = !!s.smooth && !s.step
|
|
1431
|
+
const step = s.step
|
|
1432
|
+
const connect = !!s.connectNulls
|
|
1433
|
+
let path = s.kind === 'scatter' ? '' : buildLinePath(pts, smooth, { connectNulls: connect, step })
|
|
1434
|
+
|
|
1435
|
+
let areaPath = ''
|
|
1436
|
+
if (s.kind === 'range-area' && pts.length) {
|
|
1437
|
+
// The band between lowValues and values, with both edges stroked.
|
|
1438
|
+
const hi: XY[] = []
|
|
1439
|
+
const lo: XY[] = []
|
|
1440
|
+
pts.forEach((p, i) => {
|
|
1441
|
+
const l = s.lowValues?.[i]
|
|
1442
|
+
if (!p.defined || !Number.isFinite(l)) return
|
|
1443
|
+
const yl = yA(l as number)
|
|
1444
|
+
if (!Number.isFinite(yl)) return
|
|
1445
|
+
hi.push({ x: p.x, y: p.y })
|
|
1446
|
+
lo.push({ x: p.x, y: yl })
|
|
1447
|
+
})
|
|
1448
|
+
if (hi.length >= 2) {
|
|
1449
|
+
areaPath = closedBetween(hi, lo, smooth, step)
|
|
1450
|
+
path = `${runPath(hi, smooth, step)} ${runPath(lo, smooth, step)}`
|
|
1451
|
+
}
|
|
1452
|
+
} else if (s.kind === 'area' && pts.length) {
|
|
1453
|
+
if (baselinePts) {
|
|
1454
|
+
areaPath = closedBetween(pts.map((p) => ({ x: p.x, y: p.y })), baselinePts, smooth, step)
|
|
1455
|
+
} else {
|
|
1456
|
+
// One filled polygon per contiguous run of defined points.
|
|
1457
|
+
const baseY = round(yA(log ? dom.min : Math.min(Math.max(0, dom.min), dom.max)))
|
|
1458
|
+
areaPath = definedRuns(pts, connect)
|
|
1459
|
+
.map((run) => `${runPath(run, smooth, step)} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`)
|
|
1460
|
+
.join(' ')
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
1465
|
+
// Both arrays must be present and aligned to the value array.
|
|
1466
|
+
let bandPath = ''
|
|
1467
|
+
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
1468
|
+
const upperPts: XY[] = []
|
|
1469
|
+
const lowerPts: XY[] = []
|
|
1470
|
+
for (let i = 0; i < s.values.length; i += 1) {
|
|
1471
|
+
const u = s.upperValues[i]!
|
|
1472
|
+
const lo = s.lowerValues[i]!
|
|
1473
|
+
if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
|
|
1474
|
+
if (log && (u <= 0 || lo <= 0)) continue
|
|
1475
|
+
upperPts.push({ x: px(i), y: yA(u) })
|
|
1476
|
+
lowerPts.push({ x: px(i), y: yA(lo) })
|
|
1477
|
+
}
|
|
1478
|
+
if (upperPts.length >= 2) bandPath = closedBetween(upperPts, lowerPts, smooth, step)
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const line: ChartLine = { path, areaPath, color: s.color, label: s.label, points: pts, bandPath }
|
|
1482
|
+
if (s.kind === 'range-area') line.range = true
|
|
1483
|
+
const style = styleOf(s)
|
|
1484
|
+
if (style) line.style = style
|
|
1485
|
+
if (smooth) line.smooth = true
|
|
1486
|
+
if (step) line.step = step
|
|
1487
|
+
lines.push(line)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Under `stacked100` the axis is a share of the total, not the measure, so
|
|
1491
|
+
// it is labelled as a percentage whatever `valueFormat` says. Formatting it
|
|
1492
|
+
// as currency gives an axis reading "$0 .. $100" for what are percentages -
|
|
1493
|
+
// which is what it did, unnoticed, while `stacked100` was reachable only
|
|
1494
|
+
// from config.
|
|
1495
|
+
const tickFor = (dom: NiceScale, ax: ResolvedAxis, log: boolean): ChartAxisTick[] =>
|
|
1496
|
+
dom.ticks.map((value, i) => ({
|
|
1497
|
+
value,
|
|
1498
|
+
y: yOf(dom, value, log, ax.reversed),
|
|
1499
|
+
label: !ax.labels
|
|
1500
|
+
? ''
|
|
1501
|
+
: spec.stacked100
|
|
1502
|
+
? `${round(value)}%`
|
|
1503
|
+
: axisTickLabel(value, i, ax, spec, formatChartValue),
|
|
1504
|
+
}))
|
|
1505
|
+
|
|
1506
|
+
const referenceLines: ChartRefLineGeo[] = []
|
|
1507
|
+
const referenceLinesV: ChartRefLineGeoV[] = []
|
|
1508
|
+
for (const ref of spec.referenceLines ?? []) {
|
|
1509
|
+
if (ref.axis === 'x') {
|
|
1510
|
+
const x = resolveXValue(spec, xl, ref.value)
|
|
1511
|
+
if (!Number.isFinite(x)) continue
|
|
1512
|
+
referenceLinesV.push({ x, label: ref.label ?? String(ref.value), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
|
|
1513
|
+
continue
|
|
1514
|
+
}
|
|
1515
|
+
const onRight = ref.axis === 'right'
|
|
1516
|
+
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
1517
|
+
const log = onRight ? rightLog : leftLog
|
|
1518
|
+
const v = Number(ref.value)
|
|
1519
|
+
const y = yOf(dom, v, log, onRight ? axes.y2.reversed : axes.y.reversed)
|
|
1520
|
+
if (!Number.isFinite(y)) continue
|
|
1521
|
+
referenceLines.push({
|
|
1522
|
+
y,
|
|
1523
|
+
label: ref.label ?? formatChartValue(v, spec.valueFormat, spec),
|
|
1524
|
+
color: ref.color ?? '#ef4444',
|
|
1525
|
+
dashed: ref.dashed !== false,
|
|
1526
|
+
})
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const referenceBands: ChartRefBandGeo[] = []
|
|
1530
|
+
for (const b of spec.referenceBands ?? []) {
|
|
1531
|
+
const color = b.color ?? '#64748b'
|
|
1532
|
+
const opacity = b.opacity ?? 0.08
|
|
1533
|
+
const label = b.label ?? ''
|
|
1534
|
+
if (b.axis === 'x') {
|
|
1535
|
+
let x0 = resolveXValue(spec, xl, b.from)
|
|
1536
|
+
let x1 = resolveXValue(spec, xl, b.to)
|
|
1537
|
+
if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue
|
|
1538
|
+
// On a category axis a band from A to B covers A's and B's whole slots.
|
|
1539
|
+
if (!xl.continuous) {
|
|
1540
|
+
const half = slot / 2
|
|
1541
|
+
if (x0 <= x1) { x0 -= half; x1 += half } else { x0 += half; x1 -= half }
|
|
1542
|
+
}
|
|
1543
|
+
referenceBands.push({ x: round(Math.min(x0, x1)), y: padT, w: round(Math.abs(x1 - x0)), h: plotH, color, opacity, label, axis: 'x' })
|
|
1544
|
+
continue
|
|
1545
|
+
}
|
|
1546
|
+
const onRight = b.axis === 'right'
|
|
1547
|
+
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
1548
|
+
const log = onRight ? rightLog : leftLog
|
|
1549
|
+
const rev = onRight ? axes.y2.reversed : axes.y.reversed
|
|
1550
|
+
const y0 = yOf(dom, Number(b.from), log, rev)
|
|
1551
|
+
const y1 = yOf(dom, Number(b.to), log, rev)
|
|
1552
|
+
if (!Number.isFinite(y0) || !Number.isFinite(y1)) continue
|
|
1553
|
+
referenceBands.push({ x: padL, y: Math.min(y0, y1), w: plotW, h: Math.abs(y1 - y0), color, opacity, label, axis: 'y' })
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// ---- Overlays: trendline / moving average ------------------------
|
|
1557
|
+
// For every series with an `overlay`, compute the smoothed values and
|
|
1558
|
+
// render as a dashed line in the source series' color (or overlayColor).
|
|
1559
|
+
const overlays: ChartLine[] = []
|
|
1560
|
+
for (const s of series) {
|
|
1561
|
+
if (!s.overlay) continue
|
|
1562
|
+
const log = isLogOf(s)
|
|
1563
|
+
const fit = computeOverlayFit(s.values, s.overlay, { ohlc: s.ohlc, volumes: s.volumes })
|
|
1564
|
+
const overlayVals = fit.values
|
|
1565
|
+
const color = s.overlayColor ?? s.color
|
|
1566
|
+
const toPts = (vals: ReadonlyArray<number>): ChartLinePoint[] => vals.map((v, i) => {
|
|
1567
|
+
const ok = Number.isFinite(v) && (!log || v > 0)
|
|
1568
|
+
return {
|
|
1569
|
+
x: xCenter(i),
|
|
1570
|
+
y: ok ? ySer(s, v) : NaN,
|
|
1571
|
+
label: spec.categories[i] ?? String(i),
|
|
1572
|
+
value: v,
|
|
1573
|
+
defined: ok,
|
|
1574
|
+
}
|
|
1575
|
+
})
|
|
1576
|
+
const pts = toPts(overlayVals)
|
|
1577
|
+
const path = buildLinePath(pts, !!s.smooth)
|
|
1578
|
+
// Bollinger: the middle line is the overlay; the band between the upper
|
|
1579
|
+
// and lower envelopes is its shaded `bandPath`, like a confidence band.
|
|
1580
|
+
let bandPath = ''
|
|
1581
|
+
const bb = /^bb:(\d+):(\d+(?:\.\d+)?)$/.exec(s.overlay)
|
|
1582
|
+
if (bb) {
|
|
1583
|
+
const { upper, lower } = bollingerBands(s.values, Number(bb[1]), Number(bb[2]))
|
|
1584
|
+
const up = toPts(upper).filter((p) => p.defined)
|
|
1585
|
+
const lo = toPts(lower).filter((p) => p.defined)
|
|
1586
|
+
if (up.length > 1 && lo.length > 1) {
|
|
1587
|
+
bandPath = up.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ') + ' ' +
|
|
1588
|
+
lo.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ') + ' Z'
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
overlays.push({
|
|
1592
|
+
path,
|
|
1593
|
+
areaPath: '',
|
|
1594
|
+
color,
|
|
1595
|
+
label: `${s.label} (${s.overlay})`,
|
|
1596
|
+
points: pts,
|
|
1597
|
+
...(bandPath ? { bandPath } : {}),
|
|
1598
|
+
...(fit.r2 !== undefined ? { r2: fit.r2 } : {}),
|
|
1599
|
+
...(fit.equation ? { equation: fit.equation } : {}),
|
|
1600
|
+
})
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ---- Last price: a pill on the axis at the last close ------------------
|
|
1604
|
+
if (spec.lastPriceLine) {
|
|
1605
|
+
const cfg = spec.lastPriceLine === true ? {} : spec.lastPriceLine
|
|
1606
|
+
const src = series.find((s) => s.ohlc) ?? series[0]
|
|
1607
|
+
if (src) {
|
|
1608
|
+
let last = -1
|
|
1609
|
+
for (let i = src.values.length - 1; i >= 0; i -= 1) {
|
|
1610
|
+
if (Number.isFinite(src.values[i])) { last = i; break }
|
|
1611
|
+
}
|
|
1612
|
+
if (last >= 0) {
|
|
1613
|
+
const v = src.values[last]!
|
|
1614
|
+
let prev = Number.NaN
|
|
1615
|
+
for (let i = last - 1; i >= 0; i -= 1) {
|
|
1616
|
+
if (Number.isFinite(src.values[i])) { prev = src.values[i]!; break }
|
|
1617
|
+
}
|
|
1618
|
+
const y = ySer(src, v)
|
|
1619
|
+
if (Number.isFinite(y)) {
|
|
1620
|
+
referenceLines.push({
|
|
1621
|
+
y,
|
|
1622
|
+
label: cfg.label ?? formatChartValue(v, spec.valueFormat, spec),
|
|
1623
|
+
color: cfg.color ?? (Number.isFinite(prev) && v < prev ? '#ef4444' : '#16a34a'),
|
|
1624
|
+
dashed: true,
|
|
1625
|
+
pill: true,
|
|
1626
|
+
})
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
1633
|
+
const annotations: ChartGeometry['annotations'] = []
|
|
1634
|
+
for (const a of (spec.annotations ?? [])) {
|
|
1635
|
+
let ax: number | null = null
|
|
1636
|
+
let ay: number | null = null
|
|
1637
|
+
if ('category' in a.at) {
|
|
1638
|
+
const ci = spec.categories.indexOf(a.at.category)
|
|
1639
|
+
if (ci < 0) continue
|
|
1640
|
+
ax = xCenter(ci)
|
|
1641
|
+
// Anchor to the named series' value at that category, else just
|
|
1642
|
+
// mid-plot. Picks the first matching series if `series` is set.
|
|
1643
|
+
const seriesName = a.at.series
|
|
1644
|
+
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
|
|
1645
|
+
if (s) {
|
|
1646
|
+
const v = s.values[ci]
|
|
1647
|
+
if (Number.isFinite(v)) ay = ySer(s, v as number)
|
|
1648
|
+
}
|
|
1649
|
+
if (ay == null) ay = padT + plotH / 2
|
|
1650
|
+
} else {
|
|
1651
|
+
// Raw x/y in data space. x is a value on a continuous axis, a
|
|
1652
|
+
// (fractional) category index otherwise; y projects through the left
|
|
1653
|
+
// axis. Either falls back to mid-plot when it cannot be placed.
|
|
1654
|
+
const rx = Number.isFinite(a.at.x) ? resolveXValue(spec, xl, a.at.x) : Number.NaN
|
|
1655
|
+
ax = Number.isFinite(rx) ? rx : padL + plotW / 2
|
|
1656
|
+
if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog, axes.y.reversed)
|
|
1657
|
+
else ay = padT + plotH / 2
|
|
1658
|
+
}
|
|
1659
|
+
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
1660
|
+
annotations.push({
|
|
1661
|
+
x: ax,
|
|
1662
|
+
y: ay,
|
|
1663
|
+
label: a.label,
|
|
1664
|
+
color: a.color ?? '#0f172a',
|
|
1665
|
+
placement: a.placement ?? 'top',
|
|
1666
|
+
shape: a.shape ?? 'dot',
|
|
1667
|
+
...(a.text ? { text: a.text } : {}),
|
|
1668
|
+
})
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// ---- Drawings: data-space anchors to pixels ----------------------------
|
|
1673
|
+
// A point that cannot be placed (a category the zoom window cut, a date
|
|
1674
|
+
// off the axis) drops the whole drawing; a half-resolved trend line would
|
|
1675
|
+
// point somewhere the reader never drew.
|
|
1676
|
+
const drawings: ChartDrawingGeo[] = []
|
|
1677
|
+
for (const d of spec.drawings ?? []) {
|
|
1678
|
+
const onRight = d.axis === 'right'
|
|
1679
|
+
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
1680
|
+
const log = onRight ? rightLog : leftLog
|
|
1681
|
+
const rev = onRight ? axes.y2.reversed : axes.y.reversed
|
|
1682
|
+
const pts: Array<{ x: number; y: number }> = []
|
|
1683
|
+
let ok = true
|
|
1684
|
+
for (const pt of d.points) {
|
|
1685
|
+
const x = resolveXValue(spec, xl, pt.x)
|
|
1686
|
+
const y = yOf(dom, pt.y, log, rev)
|
|
1687
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) { ok = false; break }
|
|
1688
|
+
pts.push({ x: round(x), y })
|
|
1689
|
+
}
|
|
1690
|
+
if (!ok || !pts.length) continue
|
|
1691
|
+
const color = d.color ?? '#0ea5e9'
|
|
1692
|
+
const geo: ChartDrawingGeo = { id: d.id, kind: d.kind, points: pts, color, ...(d.text ? { text: d.text } : {}) }
|
|
1693
|
+
if (d.kind === 'fib' && pts.length >= 2) {
|
|
1694
|
+
const y0 = d.points[0]!.y
|
|
1695
|
+
const y1 = d.points[1]!.y
|
|
1696
|
+
geo.levels = [0, 0.236, 0.382, 0.5, 0.618, 1].map((ratio) => {
|
|
1697
|
+
const v = y1 + (y0 - y1) * ratio
|
|
1698
|
+
return { ratio, y: yOf(dom, v, log, rev), label: `${(ratio * 100).toFixed(1)}% ${formatChartValue(v, spec.valueFormat, spec)}` }
|
|
1699
|
+
})
|
|
1700
|
+
}
|
|
1701
|
+
if (d.kind === 'hray') geo.label = formatChartValue(d.points[0]!.y, spec.valueFormat, spec)
|
|
1702
|
+
drawings.push(geo)
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
return {
|
|
1706
|
+
...empty,
|
|
1707
|
+
plot,
|
|
1708
|
+
// What a caller needs to put its own marks in this chart's coordinates.
|
|
1709
|
+
// Reported rather than recomputed, so a custom mark lands on exactly the
|
|
1710
|
+
// scale the built-in ones did - including the nice-scale rounding and the
|
|
1711
|
+
// "always include zero" rule, which are impossible to guess from outside.
|
|
1712
|
+
axes: {
|
|
1713
|
+
y: { min: leftDom.min, max: leftDom.max, log: leftLog, reversed: axes.y.reversed },
|
|
1714
|
+
y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog, reversed: axes.y2.reversed } : null,
|
|
1715
|
+
slot,
|
|
1716
|
+
count: spec.categories.length,
|
|
1717
|
+
...(xl.continuous
|
|
1718
|
+
? { x: { type: xl.continuous, min: xl.min, max: xl.max, reversed: axes.x.reversed, values: xl.values ?? [], ...(xl.log ? { log: true } : {}) } }
|
|
1719
|
+
: {}),
|
|
1720
|
+
xReversed: axes.x.reversed,
|
|
1721
|
+
labels: spec.categories,
|
|
1722
|
+
},
|
|
1723
|
+
grid: { x: axes.x.gridLines, y: axes.y.gridLines },
|
|
1724
|
+
bars,
|
|
1725
|
+
candles,
|
|
1726
|
+
boxes,
|
|
1727
|
+
errorBars,
|
|
1728
|
+
stems,
|
|
1729
|
+
lines,
|
|
1730
|
+
yTicks: tickFor(leftDom, axes.y, leftLog),
|
|
1731
|
+
y2Ticks: rightDom ? tickFor(rightDom, axes.y2, rightLog) : [],
|
|
1732
|
+
hasRightAxis,
|
|
1733
|
+
xTicks,
|
|
1734
|
+
categoryGroupTicks,
|
|
1735
|
+
xLabelRotated: xl.rotated,
|
|
1736
|
+
xLabelAngle: xl.angle,
|
|
1737
|
+
referenceLines,
|
|
1738
|
+
referenceLinesV,
|
|
1739
|
+
referenceBands,
|
|
1740
|
+
overlays,
|
|
1741
|
+
annotations,
|
|
1742
|
+
drawings,
|
|
1743
|
+
seriesLabels: spec.seriesLabels ? layoutSeriesLabels(spec, lines, plot) : [],
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** The end-label text per line series, keyed by label, with its length. */
|
|
1748
|
+
function seriesLabelTexts(spec: ChartSpec, series: ResolvedSeries[]): Map<string, number> {
|
|
1749
|
+
const out = new Map<string, number>()
|
|
1750
|
+
const fmt = typeof spec.seriesLabels === 'object' ? spec.seriesLabels.formatter : undefined
|
|
1751
|
+
for (const s of series) {
|
|
1752
|
+
if (s.kind !== 'line' && s.kind !== 'area') continue
|
|
1753
|
+
const last = [...s.values].reverse().find((v) => Number.isFinite(v))
|
|
1754
|
+
if (last === undefined) continue
|
|
1755
|
+
out.set(s.label, (fmt ? fmt(s.label, last) : s.label).length)
|
|
1756
|
+
}
|
|
1757
|
+
return out
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* A name at each line's last defined point, in the gutter to its right. The
|
|
1762
|
+
* labels are sorted by y and pushed apart by a line height, from the top
|
|
1763
|
+
* down and then back up from the plot's bottom edge, so a bundle of lines
|
|
1764
|
+
* ending together reads as a list instead of a smear.
|
|
1765
|
+
*/
|
|
1766
|
+
function layoutSeriesLabels(spec: ChartSpec, lines: ChartLine[], plot: { y: number; h: number }): ChartSeriesLabel[] {
|
|
1767
|
+
const GAP = 12
|
|
1768
|
+
const fmt = typeof spec.seriesLabels === 'object' ? spec.seriesLabels.formatter : undefined
|
|
1769
|
+
const items: ChartSeriesLabel[] = []
|
|
1770
|
+
for (const line of lines) {
|
|
1771
|
+
const last = [...line.points].reverse().find((p) => p.defined && Number.isFinite(p.y))
|
|
1772
|
+
if (!last) continue
|
|
1773
|
+
items.push({ x: round(last.x + 6), y: last.y, text: fmt ? fmt(line.label, last.value) : line.label, color: line.color, series: line.label })
|
|
1774
|
+
}
|
|
1775
|
+
items.sort((a, b) => a.y - b.y)
|
|
1776
|
+
for (let k = 1; k < items.length; k += 1) items[k]!.y = Math.max(items[k]!.y, items[k - 1]!.y + GAP)
|
|
1777
|
+
const floor = plot.y + plot.h - 2
|
|
1778
|
+
for (let k = items.length - 1; k >= 0; k -= 1) {
|
|
1779
|
+
const limit = k === items.length - 1 ? floor : items[k + 1]!.y - GAP
|
|
1780
|
+
items[k]!.y = Math.min(items[k]!.y, limit)
|
|
1781
|
+
}
|
|
1782
|
+
for (const it of items) it.y = round(Math.max(plot.y + 4, it.y))
|
|
1783
|
+
return items
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// ---- Bullet ---------------------------------------------------------------
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* @internal Lay out the bullet family. Called by buildChart.
|
|
1790
|
+
*
|
|
1791
|
+
* One row per category: qualitative ranges (poor / ok / good) as full-height
|
|
1792
|
+
* bands, the measure as a bar at half height, the target as a tick. The
|
|
1793
|
+
* first series is the measure; `targets` on it (or a second series) sets the
|
|
1794
|
+
* ticks; `bulletRanges` (or `gaugeRanges`) draws the bands behind every row.
|
|
1795
|
+
*/
|
|
1796
|
+
export function layoutBullet(ctx: LayoutCtx): ChartGeometry {
|
|
1797
|
+
const { spec, series, empty, width, height, frame, axes } = ctx
|
|
1798
|
+
const src = series[0]
|
|
1799
|
+
if (!src || !spec.categories.length) return { ...empty }
|
|
1800
|
+
const targets = src.targets ?? series[1]?.values ?? []
|
|
1801
|
+
const ranges = spec.bulletRanges ?? spec.gaugeRanges ?? []
|
|
1802
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
1803
|
+
const padL = axes.x.labels ? Math.min(150, 18 + maxLabel * 6.4) : 12
|
|
1804
|
+
const padR = 16
|
|
1805
|
+
const padT = 8 + frame.top
|
|
1806
|
+
const padB = (axes.y.labels ? 26 : 8) + (axes.y.title ? 16 : 0) + frame.bottom
|
|
1807
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1808
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1809
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1810
|
+
let vMax = 0
|
|
1811
|
+
for (const v of src.values) if (Number.isFinite(v) && v > vMax) vMax = v
|
|
1812
|
+
for (const t of targets) if (Number.isFinite(t) && t > vMax) vMax = t
|
|
1813
|
+
for (const r of ranges) if (Number.isFinite(r.to) && r.to > vMax) vMax = r.to
|
|
1814
|
+
const dom = axisScale(0, vMax > 0 ? vMax : 1, axes.y)
|
|
1815
|
+
const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
|
|
1816
|
+
const n = spec.categories.length
|
|
1817
|
+
const slot = plotH / n
|
|
1818
|
+
const rowPad = slot * 0.25
|
|
1819
|
+
const rowH = slot - rowPad
|
|
1820
|
+
const bullets: ChartBullet[] = []
|
|
1821
|
+
spec.categories.forEach((label, i) => {
|
|
1822
|
+
const v = src.values[i]
|
|
1823
|
+
const t = targets[i]
|
|
1824
|
+
const y = round(padT + slot * i + rowPad / 2)
|
|
1825
|
+
const value = Number.isFinite(v) ? (v as number) : 0
|
|
1826
|
+
bullets.push({
|
|
1827
|
+
x: padL,
|
|
1828
|
+
y,
|
|
1829
|
+
w: plotW,
|
|
1830
|
+
h: round(rowH),
|
|
1831
|
+
measureW: Math.max(0, xOf(Math.max(dom.min, Math.min(dom.max, value))) - padL),
|
|
1832
|
+
targetX: Number.isFinite(t) ? xOf(Math.max(dom.min, Math.min(dom.max, t as number))) : null,
|
|
1833
|
+
ranges: ranges.map((r) => {
|
|
1834
|
+
const x0 = xOf(Math.max(dom.min, Math.min(dom.max, r.from)))
|
|
1835
|
+
const x1 = xOf(Math.max(dom.min, Math.min(dom.max, r.to)))
|
|
1836
|
+
return { x: Math.min(x0, x1), w: Math.abs(x1 - x0), color: r.color }
|
|
1837
|
+
}),
|
|
1838
|
+
color: src.colors?.[i] ?? src.color,
|
|
1839
|
+
label,
|
|
1840
|
+
series: src.label,
|
|
1841
|
+
value,
|
|
1842
|
+
target: Number.isFinite(t) ? (t as number) : null,
|
|
1843
|
+
})
|
|
1844
|
+
})
|
|
1845
|
+
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value, i) => ({
|
|
1846
|
+
label: axes.y.labels ? axisTickLabel(value, i, axes.y, spec, formatChartValue) : '',
|
|
1847
|
+
x: xOf(value),
|
|
1848
|
+
}))
|
|
1849
|
+
const catTicks: ChartAxisTick[] = axes.x.labels
|
|
1850
|
+
? spec.categories.map((label, i) => ({ value: i, y: round(padT + slot * i + slot / 2), label }))
|
|
1851
|
+
: []
|
|
1852
|
+
return {
|
|
1853
|
+
...empty,
|
|
1854
|
+
plot,
|
|
1855
|
+
axes: { y: { min: dom.min, max: dom.max, log: false }, y2: null, slot, count: n, labels: spec.categories },
|
|
1856
|
+
grid: { x: axes.y.gridLines, y: false },
|
|
1857
|
+
orientation: 'horizontal',
|
|
1858
|
+
bullets,
|
|
1859
|
+
valueTicks,
|
|
1860
|
+
catTicks,
|
|
1861
|
+
legend: [{ label: src.label, color: src.color }],
|
|
1862
|
+
}
|
|
1863
|
+
}
|