@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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow layouts: waterfall, funnel and sankey.
|
|
3
|
+
*/
|
|
4
|
+
import type { ChartBar, ChartLine, ChartAxisTick, ChartCategoryTick, ChartFunnelSegment, ChartSankeyNode, ChartSankeyLink, ChartGeometry, LayoutCtx } from './chart-types'
|
|
5
|
+
import { round, niceScale, formatChartValue, pickContrastText, DEFAULT_PALETTE } from './chart-scale'
|
|
6
|
+
|
|
7
|
+
// ---- Waterfall ----------------------------------------------------
|
|
8
|
+
// First series provides the values. Each non-total bar starts at the
|
|
9
|
+
// running cumulative sum; total bars (waterfallTotals[i]) span from 0 to
|
|
10
|
+
// that sum, and a total with a value of its own sets the sum to it first
|
|
11
|
+
// (the opening balance of a bridge, or a subtotal supplied by the caller).
|
|
12
|
+
// Color is derived from sign + total flag, with optional palette overrides
|
|
13
|
+
// via spec.waterfallColors.
|
|
14
|
+
/** @internal Lay out the waterfall family. Called by buildChart. */
|
|
15
|
+
export function layoutWaterfall(ctx: LayoutCtx): ChartGeometry {
|
|
16
|
+
const { spec, series, width, height, empty, frame, axes } = ctx
|
|
17
|
+
const src = series[0]
|
|
18
|
+
if (!src) return { ...empty }
|
|
19
|
+
const colors = spec.waterfallColors ?? {}
|
|
20
|
+
const positive = colors.positive ?? '#16a34a'
|
|
21
|
+
const negative = colors.negative ?? '#ef4444'
|
|
22
|
+
const total = colors.total ?? '#475569'
|
|
23
|
+
|
|
24
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
25
|
+
const padL = 48 + (axes.y.title ? 16 : 0)
|
|
26
|
+
const padR = 12
|
|
27
|
+
const padT = 10 + frame.top
|
|
28
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
29
|
+
// The same rule as a bar chart: labels tilt when the widest does not fit
|
|
30
|
+
// its slot. Judged by count alone, nine categories on a 900px chart were
|
|
31
|
+
// flagged as rotated with no angle to go with it, and drew truncated.
|
|
32
|
+
const xLabelRotated = typeof axes.x.labelRotation === 'number' ? axes.x.labelRotation !== 0 : maxLabel * 6.2 + 8 > plotW / Math.max(1, spec.categories.length)
|
|
33
|
+
const xLabelAngle = typeof axes.x.labelRotation === 'number' ? axes.x.labelRotation : xLabelRotated ? -40 : 0
|
|
34
|
+
const padB = (xLabelRotated ? 54 : 28) + (axes.x.title ? 16 : 0) + frame.bottom
|
|
35
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
36
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
37
|
+
|
|
38
|
+
// Compute the running cumulative + per-bar (from, to) pairs.
|
|
39
|
+
const totals = spec.waterfallTotals ?? []
|
|
40
|
+
const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
|
|
41
|
+
let cum = 0
|
|
42
|
+
spec.categories.forEach((_, i) => {
|
|
43
|
+
const v = src.values[i] ?? 0
|
|
44
|
+
const isTotal = !!totals[i]
|
|
45
|
+
if (isTotal) {
|
|
46
|
+
// An opening "Revenue 4300" is a total with a value: it anchors the
|
|
47
|
+
// running sum. Read as a computed subtotal it drew as zero and every
|
|
48
|
+
// bar after it hung below the axis.
|
|
49
|
+
if (Number.isFinite(v) && v !== 0) cum = v
|
|
50
|
+
pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
|
|
51
|
+
} else {
|
|
52
|
+
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
|
|
53
|
+
cum += v
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
// Y-axis domain spans every visited level (including 0).
|
|
57
|
+
let dMin = 0, dMax = 0
|
|
58
|
+
for (const p of pairs) {
|
|
59
|
+
if (p.from < dMin) dMin = p.from
|
|
60
|
+
if (p.to < dMin) dMin = p.to
|
|
61
|
+
if (p.from > dMax) dMax = p.from
|
|
62
|
+
if (p.to > dMax) dMax = p.to
|
|
63
|
+
}
|
|
64
|
+
const dom = niceScale(dMin, dMax)
|
|
65
|
+
const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
|
|
66
|
+
|
|
67
|
+
const slotW = plotW / Math.max(1, spec.categories.length)
|
|
68
|
+
const barPad = slotW * 0.2
|
|
69
|
+
const barW = Math.max(1, slotW - barPad)
|
|
70
|
+
const bars: ChartBar[] = pairs.map((p, i) => {
|
|
71
|
+
const yTop = yOfW(Math.max(p.from, p.to))
|
|
72
|
+
const yBot = yOfW(Math.min(p.from, p.to))
|
|
73
|
+
const x = padL + slotW * i + barPad / 2
|
|
74
|
+
const color = p.isTotal ? total : p.value >= 0 ? positive : negative
|
|
75
|
+
return {
|
|
76
|
+
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
77
|
+
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value, index: i,
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
81
|
+
const connectors: ChartLine[] = [{
|
|
82
|
+
path: pairs
|
|
83
|
+
.map((p, i) => {
|
|
84
|
+
const x0 = padL + slotW * i + barPad / 2 + barW
|
|
85
|
+
const y = yOfW(p.to)
|
|
86
|
+
const x1 = padL + slotW * (i + 1) + barPad / 2
|
|
87
|
+
// Skip the final connector beyond the last bar.
|
|
88
|
+
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
|
|
89
|
+
})
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(' '),
|
|
92
|
+
areaPath: '',
|
|
93
|
+
color: 'var(--sg-muted, #94a3b8)',
|
|
94
|
+
label: '',
|
|
95
|
+
points: [],
|
|
96
|
+
}]
|
|
97
|
+
const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
98
|
+
label,
|
|
99
|
+
x: round(padL + slotW * i + slotW / 2),
|
|
100
|
+
}))
|
|
101
|
+
const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
|
|
102
|
+
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
|
|
103
|
+
}))
|
|
104
|
+
return {
|
|
105
|
+
...empty,
|
|
106
|
+
plot,
|
|
107
|
+
bars,
|
|
108
|
+
lines: connectors,
|
|
109
|
+
yTicks,
|
|
110
|
+
xTicks,
|
|
111
|
+
xLabelRotated,
|
|
112
|
+
xLabelAngle,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Funnel -------------------------------------------------------
|
|
117
|
+
// One series of strictly-decreasing values gets rendered as a stack
|
|
118
|
+
// of horizontal trapezoids: each level's width is proportional to its
|
|
119
|
+
// value relative to the largest, slope automatically links level N+1
|
|
120
|
+
// narrower than level N. Labels show value, conversion vs. top, and
|
|
121
|
+
// step drop-off.
|
|
122
|
+
/** @internal Lay out the funnel family. Called by buildChart. */
|
|
123
|
+
export function layoutFunnel(ctx: LayoutCtx): ChartGeometry {
|
|
124
|
+
const { spec, series, width, height, empty, frame } = ctx
|
|
125
|
+
const src = series[0]
|
|
126
|
+
if (!src || !src.values.length) return { ...empty }
|
|
127
|
+
const padL = 20, padR = 20, padT = 16 + frame.top, padB = 16 + frame.bottom
|
|
128
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
129
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
130
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
131
|
+
const n = src.values.length
|
|
132
|
+
const stepH = plotH / n
|
|
133
|
+
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
|
|
134
|
+
const top = src.values[0] ?? 0
|
|
135
|
+
const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
|
|
136
|
+
const cx = padL + plotW / 2
|
|
137
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
138
|
+
const shape = spec.funnelShape ?? 'trapezoid'
|
|
139
|
+
const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
|
|
140
|
+
const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
|
|
141
|
+
const y0 = padT + stepH * i
|
|
142
|
+
const y1 = y0 + stepH
|
|
143
|
+
let path: string
|
|
144
|
+
if (shape === 'pyramid') {
|
|
145
|
+
// Widest at the bottom: a pyramid of levels, each a slice of one big
|
|
146
|
+
// triangle. The top level is the point.
|
|
147
|
+
const wTop = (plotW * i) / n
|
|
148
|
+
const wBot = (plotW * (i + 1)) / n
|
|
149
|
+
path = `M${cx - wTop / 2},${y0} L${cx + wTop / 2},${y0} L${cx + wBot / 2},${y1} L${cx - wBot / 2},${y1} Z`
|
|
150
|
+
} else if (shape === 'cone') {
|
|
151
|
+
// One triangle, sliced: the width at each level follows its share of
|
|
152
|
+
// the height, so the whole funnel is a cone that tapers to a point.
|
|
153
|
+
const wTop = plotW * (1 - i / n)
|
|
154
|
+
const wBot = plotW * (1 - (i + 1) / n)
|
|
155
|
+
path = `M${cx - wTop / 2},${y0} L${cx + wTop / 2},${y0} L${cx + wBot / 2},${y1} L${cx - wBot / 2},${y1} Z`
|
|
156
|
+
} else {
|
|
157
|
+
const w0 = widthAt(v)
|
|
158
|
+
const w1 = widthAt(next)
|
|
159
|
+
path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
|
|
160
|
+
}
|
|
161
|
+
const color = src.color ?? palette[i % palette.length]!
|
|
162
|
+
return {
|
|
163
|
+
path, color,
|
|
164
|
+
label: spec.categories[i] ?? src.label,
|
|
165
|
+
value: v,
|
|
166
|
+
conversion: top > 0 ? v / top : 0,
|
|
167
|
+
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
168
|
+
cx,
|
|
169
|
+
cy: (y0 + y1) / 2,
|
|
170
|
+
textColor: pickContrastText(color),
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
return {
|
|
174
|
+
...empty,
|
|
175
|
+
plot,
|
|
176
|
+
funnelSegments: segments,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- Sankey -------------------------------------------------------
|
|
181
|
+
// Multi-column flow layout. Each node assigned to a column by longest
|
|
182
|
+
// path from any source. Within a column, nodes are stacked vertically;
|
|
183
|
+
// height proportional to max(totalIn, totalOut). Links render as
|
|
184
|
+
// bezier ribbons whose width is the link value (in pixels).
|
|
185
|
+
/** @internal Lay out the sankey family. Called by buildChart. */
|
|
186
|
+
export function layoutSankey(ctx: LayoutCtx): ChartGeometry {
|
|
187
|
+
const { spec, width, height, empty, frame } = ctx
|
|
188
|
+
const nodes = spec.sankeyNodes ?? []
|
|
189
|
+
const links = spec.sankeyLinks ?? []
|
|
190
|
+
if (!nodes.length || !links.length) return { ...empty }
|
|
191
|
+
const padL = 10, padR = 10, padT = 14 + frame.top, padB = 14 + frame.bottom
|
|
192
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
193
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
194
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
195
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
196
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
|
197
|
+
// Column = longest path from any node with no incoming edges.
|
|
198
|
+
const targets = new Set(links.map((l) => l.target))
|
|
199
|
+
const sources = nodes.filter((n) => !targets.has(n.id))
|
|
200
|
+
const column = new Map<string, number>()
|
|
201
|
+
function visit(id: string, depth: number, seen: Set<string>) {
|
|
202
|
+
if (seen.has(id)) return
|
|
203
|
+
seen.add(id)
|
|
204
|
+
const cur = column.get(id) ?? 0
|
|
205
|
+
if (depth > cur || !column.has(id)) column.set(id, depth)
|
|
206
|
+
for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
|
|
207
|
+
seen.delete(id)
|
|
208
|
+
}
|
|
209
|
+
for (const s of sources) visit(s.id, 0, new Set())
|
|
210
|
+
// Cover any nodes with no path from a source (orphan rings).
|
|
211
|
+
for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
|
|
212
|
+
const maxCol = Math.max(...column.values())
|
|
213
|
+
const cols = maxCol + 1
|
|
214
|
+
const nodeW = 14
|
|
215
|
+
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
|
|
216
|
+
// Totals per node.
|
|
217
|
+
const totalIn = new Map<string, number>()
|
|
218
|
+
const totalOut = new Map<string, number>()
|
|
219
|
+
for (const l of links) {
|
|
220
|
+
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
|
|
221
|
+
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
|
|
222
|
+
}
|
|
223
|
+
// Per-column groups + max total in that column.
|
|
224
|
+
const byCol: Map<number, string[]> = new Map()
|
|
225
|
+
for (const n of nodes) {
|
|
226
|
+
const c = column.get(n.id) ?? 0
|
|
227
|
+
const arr = byCol.get(c) ?? []
|
|
228
|
+
arr.push(n.id); byCol.set(c, arr)
|
|
229
|
+
}
|
|
230
|
+
// Per-column total height + node height scale.
|
|
231
|
+
let maxColTotal = 0
|
|
232
|
+
for (const ids of byCol.values()) {
|
|
233
|
+
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
|
|
234
|
+
if (t > maxColTotal) maxColTotal = t
|
|
235
|
+
}
|
|
236
|
+
if (maxColTotal === 0) return { ...empty, plot }
|
|
237
|
+
const nodeGapPx = 8
|
|
238
|
+
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
|
|
239
|
+
const placed: ChartSankeyNode[] = []
|
|
240
|
+
for (const [c, ids] of byCol) {
|
|
241
|
+
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
|
|
242
|
+
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
|
|
243
|
+
let yCursor = padT + (plotH - totalH) / 2
|
|
244
|
+
const xCol = padL + c * (nodeW + gapBetweenColumns)
|
|
245
|
+
ids.forEach((id, idx) => {
|
|
246
|
+
const node = nodeById.get(id)!
|
|
247
|
+
const h = heights[idx]!
|
|
248
|
+
placed.push({
|
|
249
|
+
id,
|
|
250
|
+
label: node.label ?? id,
|
|
251
|
+
color: node.color ?? palette[(placed.length) % palette.length]!,
|
|
252
|
+
x: xCol, y: yCursor, w: nodeW, h,
|
|
253
|
+
column: c,
|
|
254
|
+
totalIn: totalIn.get(id) ?? 0,
|
|
255
|
+
totalOut: totalOut.get(id) ?? 0,
|
|
256
|
+
})
|
|
257
|
+
yCursor += h + nodeGapPx
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
const placedById = new Map(placed.map((n) => [n.id, n]))
|
|
261
|
+
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
262
|
+
const inCursor = new Map<string, number>()
|
|
263
|
+
const outCursor = new Map<string, number>()
|
|
264
|
+
const builtLinks: ChartSankeyLink[] = []
|
|
265
|
+
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
266
|
+
const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
|
|
267
|
+
for (const link of sortedLinks) {
|
|
268
|
+
const a = placedById.get(link.source)
|
|
269
|
+
const b = placedById.get(link.target)
|
|
270
|
+
if (!a || !b) continue
|
|
271
|
+
const linkH = Math.max(1, link.value * heightScale)
|
|
272
|
+
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
|
|
273
|
+
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
|
|
274
|
+
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
|
|
275
|
+
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
|
|
276
|
+
const x0 = a.x + a.w
|
|
277
|
+
const x1 = b.x
|
|
278
|
+
const mid = (x0 + x1) / 2
|
|
279
|
+
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
|
|
280
|
+
builtLinks.push({
|
|
281
|
+
path, color: link.color ?? a.color, width: linkH,
|
|
282
|
+
source: link.source, target: link.target, value: link.value,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
|
|
286
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number formatting for chart values: the compact `1.2k` tick label and
|
|
3
|
+
* `formatChartValue`, which honours a spec's `valueFormat`, `locale` and
|
|
4
|
+
* `currency`.
|
|
5
|
+
*
|
|
6
|
+
* A leaf of its own, and deliberately small. The chart engine and the lazily
|
|
7
|
+
* loaded summary (chart-summary.ts) both need it, and a bundler hoists
|
|
8
|
+
* whatever the base chunk and a lazy chunk share into a third chunk. When
|
|
9
|
+
* that shared module was all of chart-scale.ts the hoist cost the base
|
|
10
|
+
* bundle a kilobyte of gzip locality for no new code; two functions cost a
|
|
11
|
+
* few dozen bytes. chart-scale.ts re-exports both, so importers of the old
|
|
12
|
+
* home keep working.
|
|
13
|
+
*/
|
|
14
|
+
import { getNumberFormatter } from './cell-formatting'
|
|
15
|
+
import type { ChartFormatLocale, ChartValueFormat } from './chart-types'
|
|
16
|
+
|
|
17
|
+
/** @internal Locale-free compact tick label (1.2k / 1.2M). */
|
|
18
|
+
export function fmtTick(n: number): string {
|
|
19
|
+
const abs = Math.abs(n)
|
|
20
|
+
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
|
|
21
|
+
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
|
|
22
|
+
return String(Math.round(n * 100) / 100)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
27
|
+
*
|
|
28
|
+
* Two modes, on purpose. With no `locale` and no `currency` this is the original
|
|
29
|
+
* locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
|
|
30
|
+
* `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
|
|
31
|
+
* and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
|
|
32
|
+
* compact suffixes all follow the locale.
|
|
33
|
+
*
|
|
34
|
+
* Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
|
|
35
|
+
* compact notation is not the same string even for `en-US` (`1.2K`, capital),
|
|
36
|
+
* so formatting everything through it would silently restyle every axis in every
|
|
37
|
+
* existing chart. Opting in is the only version of this that is not a surprise.
|
|
38
|
+
*/
|
|
39
|
+
export function formatChartValue(
|
|
40
|
+
n: number,
|
|
41
|
+
format?: ChartValueFormat,
|
|
42
|
+
opts?: ChartFormatLocale,
|
|
43
|
+
): string {
|
|
44
|
+
if (!Number.isFinite(n)) return ''
|
|
45
|
+
const localized = opts && (opts.locale || opts.currency)
|
|
46
|
+
if (localized) {
|
|
47
|
+
// Compact notation because these are axis ticks and data labels, where a
|
|
48
|
+
// full-precision number is what makes an axis unreadable.
|
|
49
|
+
const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal'
|
|
50
|
+
const o: Intl.NumberFormatOptions = { notation: 'compact', maximumFractionDigits: 1 }
|
|
51
|
+
if (style === 'currency') {
|
|
52
|
+
o.style = 'currency'
|
|
53
|
+
// `style: 'currency'` throws without a code, so fall back to the symbol
|
|
54
|
+
// this used to hard-code rather than refusing to draw the chart.
|
|
55
|
+
o.currency = opts!.currency || 'USD'
|
|
56
|
+
} else if (style === 'percent') {
|
|
57
|
+
o.style = 'percent'
|
|
58
|
+
}
|
|
59
|
+
return getNumberFormatter(opts!.locale, o).format(n)
|
|
60
|
+
}
|
|
61
|
+
if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
|
|
62
|
+
if (format === 'percent') {
|
|
63
|
+
const p = n * 100
|
|
64
|
+
return `${Math.round(p * 10) / 10}%`
|
|
65
|
+
}
|
|
66
|
+
return fmtTick(n)
|
|
67
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid layouts: heatmap and the calendar heatmap.
|
|
3
|
+
*/
|
|
4
|
+
import type { ChartAxisTick, ChartCategoryTick, ChartCalendarCell, ChartHeatmapCell, ChartGeometry, LayoutCtx } from './chart-types'
|
|
5
|
+
import { round, formatChartValue, resolveColorScale, sampleGradient, pickContrastText, thinCategoryTicks } from './chart-scale'
|
|
6
|
+
|
|
7
|
+
// ---- Calendar heatmap --------------------------------------------
|
|
8
|
+
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
9
|
+
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
10
|
+
// sequential color scale. Days with no value render blank (border only)
|
|
11
|
+
// so missing data is visually obvious.
|
|
12
|
+
/** @internal Lay out the calendar family. Called by buildChart. */
|
|
13
|
+
export function layoutCalendar(ctx: LayoutCtx): ChartGeometry {
|
|
14
|
+
const { spec, width, height, theme, empty, frame } = ctx
|
|
15
|
+
const values = spec.calendarValues ?? []
|
|
16
|
+
if (!values.length && !spec.calendarStart) return { ...empty }
|
|
17
|
+
// Build a value lookup + figure out the date range.
|
|
18
|
+
const valueByDate = new Map<string, number>()
|
|
19
|
+
let vMin = Infinity, vMax = -Infinity
|
|
20
|
+
for (const v of values) {
|
|
21
|
+
valueByDate.set(v.date, v.value)
|
|
22
|
+
if (Number.isFinite(v.value)) {
|
|
23
|
+
if (v.value < vMin) vMin = v.value
|
|
24
|
+
if (v.value > vMax) vMax = v.value
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
28
|
+
if (vMin === vMax) vMax = vMin + 1
|
|
29
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
30
|
+
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
31
|
+
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
32
|
+
// span the data + round to whole weeks (Sun..Sat).
|
|
33
|
+
const sorted = values.map((v) => v.date).sort()
|
|
34
|
+
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
|
|
35
|
+
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
|
|
36
|
+
const start = new Date(startStr + 'T00:00:00Z')
|
|
37
|
+
const end = new Date(endStr + 'T00:00:00Z')
|
|
38
|
+
// Roll start back to its Sunday, end forward to its Saturday.
|
|
39
|
+
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
|
|
40
|
+
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
|
|
41
|
+
const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
|
|
42
|
+
const weeks = Math.ceil(totalDays / 7)
|
|
43
|
+
const padL = 36, padR = 80, padT = 26 + frame.top, padB = 16 + frame.bottom
|
|
44
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
45
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
46
|
+
// Cell size: fit weeks across width, 7 rows down height.
|
|
47
|
+
const cellW = Math.floor(plotW / weeks)
|
|
48
|
+
const cellH = Math.floor(plotH / 7)
|
|
49
|
+
const cellSize = Math.max(6, Math.min(cellW, cellH))
|
|
50
|
+
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
|
|
51
|
+
const cells: ChartCalendarCell[] = []
|
|
52
|
+
let lastMonth = -1
|
|
53
|
+
const monthTicks: ChartCategoryTick[] = []
|
|
54
|
+
for (let i = 0; i < totalDays; i += 1) {
|
|
55
|
+
const day = new Date(start.getTime() + i * 86_400_000)
|
|
56
|
+
const col = Math.floor(i / 7)
|
|
57
|
+
const row = i % 7
|
|
58
|
+
const date = day.toISOString().slice(0, 10)
|
|
59
|
+
const has = valueByDate.has(date)
|
|
60
|
+
const v = valueByDate.get(date) ?? 0
|
|
61
|
+
cells.push({
|
|
62
|
+
x: padL + col * cellSize,
|
|
63
|
+
y: padT + row * cellSize,
|
|
64
|
+
size: cellSize,
|
|
65
|
+
date, value: v,
|
|
66
|
+
defined: has,
|
|
67
|
+
color: has ? colorAt(v) : 'transparent',
|
|
68
|
+
})
|
|
69
|
+
// A month label only for a month inside the range: the end is rolled
|
|
70
|
+
// forward to its Saturday, and a December that ends on a Thursday used
|
|
71
|
+
// to pick up a "Jan" for the two padding days of the next year.
|
|
72
|
+
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth && date >= startStr && date <= endStr) {
|
|
73
|
+
lastMonth = day.getUTCMonth()
|
|
74
|
+
monthTicks.push({
|
|
75
|
+
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
76
|
+
x: padL + col * cellSize,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
81
|
+
const t = i / 4
|
|
82
|
+
const value = legendValue(vMin, vMax, t)
|
|
83
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
84
|
+
})
|
|
85
|
+
return {
|
|
86
|
+
...empty,
|
|
87
|
+
plot,
|
|
88
|
+
calendarCells: cells,
|
|
89
|
+
calendarMonthTicks: monthTicks,
|
|
90
|
+
calendarLegend: legend,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Heatmap ------------------------------------------------------
|
|
95
|
+
// Each series is one row, series.values are the cells across categories.
|
|
96
|
+
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
97
|
+
// global value range. Cell text contrasts black/white against the cell.
|
|
98
|
+
/**
|
|
99
|
+
* A legend step between the extremes, rounded to the precision the range
|
|
100
|
+
* earns: whole numbers when the steps are a unit or more apart, so a count
|
|
101
|
+
* legend reads "20, 94, 167" rather than "20, 93.5, 167", and enough
|
|
102
|
+
* decimals to tell the steps apart when they are not.
|
|
103
|
+
*/
|
|
104
|
+
function legendValue(vMin: number, vMax: number, t: number): number {
|
|
105
|
+
const raw = vMin + (vMax - vMin) * t
|
|
106
|
+
const step = Math.abs(vMax - vMin) / 4
|
|
107
|
+
if (!(step > 0)) return raw
|
|
108
|
+
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)) + 1)
|
|
109
|
+
return Number(raw.toFixed(decimals))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @internal Lay out the heatmap family. Called by buildChart. */
|
|
113
|
+
export function layoutHeatmap(ctx: LayoutCtx): ChartGeometry {
|
|
114
|
+
const { spec, series, width, height, theme, empty, frame } = ctx
|
|
115
|
+
if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
|
|
116
|
+
// Layout: left gutter for row labels, bottom for column labels.
|
|
117
|
+
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
|
|
118
|
+
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
|
|
119
|
+
const padR = 64 // room for the right-side legend bar
|
|
120
|
+
const padT = 12 + frame.top
|
|
121
|
+
const padB = 32 + frame.bottom
|
|
122
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
123
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
124
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
125
|
+
const cellW = plotW / spec.categories.length
|
|
126
|
+
const cellH = plotH / series.length
|
|
127
|
+
// Resolve value range across the whole matrix.
|
|
128
|
+
let vMin = Infinity, vMax = -Infinity
|
|
129
|
+
for (const s of series) for (const v of s.values) {
|
|
130
|
+
if (!Number.isFinite(v)) continue
|
|
131
|
+
if (v < vMin) vMin = v
|
|
132
|
+
if (v > vMax) vMax = v
|
|
133
|
+
}
|
|
134
|
+
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
135
|
+
if (vMin === vMax) vMax = vMin + 1
|
|
136
|
+
// Pick the palette stops.
|
|
137
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
138
|
+
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
139
|
+
const heatmapCells: ChartHeatmapCell[] = []
|
|
140
|
+
series.forEach((s, ri) => {
|
|
141
|
+
s.values.forEach((v, ci) => {
|
|
142
|
+
if (!Number.isFinite(v)) return
|
|
143
|
+
const color = colorAt(v)
|
|
144
|
+
heatmapCells.push({
|
|
145
|
+
x: round(padL + cellW * ci),
|
|
146
|
+
y: round(padT + cellH * ri),
|
|
147
|
+
w: round(cellW),
|
|
148
|
+
h: round(cellH),
|
|
149
|
+
color,
|
|
150
|
+
textColor: pickContrastText(color),
|
|
151
|
+
value: v,
|
|
152
|
+
rowLabel: s.label,
|
|
153
|
+
colLabel: spec.categories[ci] ?? '',
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
|
|
158
|
+
value: i,
|
|
159
|
+
y: round(padT + cellH * i + cellH / 2),
|
|
160
|
+
label: s.label,
|
|
161
|
+
}))
|
|
162
|
+
// Column labels thin out the way a bar chart's do: twenty-four hours at
|
|
163
|
+
// 33px a cell wrote "00:0001:0002:00" across the foot of the matrix.
|
|
164
|
+
const heatmapColTicks: ChartCategoryTick[] = thinCategoryTicks(spec.categories, (i) => round(padL + cellW * i + cellW / 2), cellW, false)
|
|
165
|
+
// Legend: sample 5 stops across the range.
|
|
166
|
+
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
167
|
+
const t = i / 4
|
|
168
|
+
const value = legendValue(vMin, vMax, t)
|
|
169
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
170
|
+
})
|
|
171
|
+
return {
|
|
172
|
+
...empty,
|
|
173
|
+
plot,
|
|
174
|
+
heatmapCells,
|
|
175
|
+
heatmapRowTicks,
|
|
176
|
+
heatmapColTicks,
|
|
177
|
+
heatmapLegend,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchy layouts: the squarified tree-map.
|
|
3
|
+
*/
|
|
4
|
+
import type { ChartTreemapCell, TreeNode, ChartGeometry, LayoutCtx } from './chart-types'
|
|
5
|
+
import { round, pickContrastText, DEFAULT_PALETTE } from './chart-scale'
|
|
6
|
+
|
|
7
|
+
// ---- Tree-map -----------------------------------------------------
|
|
8
|
+
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
9
|
+
// partitions its rectangle in proportion to its children, picking the
|
|
10
|
+
// split orientation that keeps aspect ratios closest to 1.
|
|
11
|
+
/** @internal Lay out the treemap family. Called by buildChart. */
|
|
12
|
+
export function layoutTreemap(ctx: LayoutCtx): ChartGeometry {
|
|
13
|
+
const { spec, width, height, empty, frame } = ctx
|
|
14
|
+
const root = spec.treemap ?? spec.tree
|
|
15
|
+
if (!root) return { ...empty }
|
|
16
|
+
const padL = 4, padR = 4, padT = 4 + frame.top, padB = 4 + frame.bottom
|
|
17
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
18
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
19
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
20
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
21
|
+
const cells: ChartTreemapCell[] = []
|
|
22
|
+
function totalOf(n: TreeNode): number {
|
|
23
|
+
if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
|
|
24
|
+
return Math.max(0, n.value ?? 0)
|
|
25
|
+
}
|
|
26
|
+
function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
|
|
27
|
+
if (!items.length || w <= 0 || h <= 0) return
|
|
28
|
+
const totals = items.map(totalOf)
|
|
29
|
+
const sum = totals.reduce((a, b) => a + b, 0)
|
|
30
|
+
if (sum <= 0) return
|
|
31
|
+
// Process largest-first so big items dominate the first row.
|
|
32
|
+
const ordered = items
|
|
33
|
+
.map((n, i) => ({ node: n, value: totals[i]! }))
|
|
34
|
+
.sort((a, b) => b.value - a.value)
|
|
35
|
+
let cx = x, cy = y, cw = w, ch = h, remaining = sum
|
|
36
|
+
let row: typeof ordered = []
|
|
37
|
+
const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
|
|
38
|
+
if (rowSum <= 0) return Infinity
|
|
39
|
+
const rowArea = rowSum * scale
|
|
40
|
+
const rowSide = rowArea / shortSide
|
|
41
|
+
let worst = 0
|
|
42
|
+
for (const v of vals) {
|
|
43
|
+
const cell = v * scale
|
|
44
|
+
const long = cell / rowSide
|
|
45
|
+
const r = Math.max(shortSide / long, long / shortSide)
|
|
46
|
+
if (r > worst) worst = r
|
|
47
|
+
}
|
|
48
|
+
return worst
|
|
49
|
+
}
|
|
50
|
+
function flushRow() {
|
|
51
|
+
if (!row.length) return
|
|
52
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
53
|
+
const scale = (cw * ch) / remaining
|
|
54
|
+
const horizontal = cw >= ch
|
|
55
|
+
const shortSide = horizontal ? ch : cw
|
|
56
|
+
const rowSide = (rowSum * scale) / shortSide
|
|
57
|
+
let offset = 0
|
|
58
|
+
for (const it of row) {
|
|
59
|
+
const cellSize = (it.value * scale) / rowSide
|
|
60
|
+
const cx2 = horizontal ? cx : cx + offset
|
|
61
|
+
const cy2 = horizontal ? cy + offset : cy
|
|
62
|
+
const ww = horizontal ? rowSide : cellSize
|
|
63
|
+
const hh = horizontal ? cellSize : rowSide
|
|
64
|
+
const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
|
|
65
|
+
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
66
|
+
if (it.node.children?.length) {
|
|
67
|
+
cells.push({
|
|
68
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
69
|
+
color, textColor: pickContrastText(color),
|
|
70
|
+
name: it.node.name, value: it.value, depth,
|
|
71
|
+
})
|
|
72
|
+
const labelH = Math.min(18, hh * 0.25)
|
|
73
|
+
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
|
|
74
|
+
} else {
|
|
75
|
+
cells.push({
|
|
76
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
77
|
+
color, textColor: pickContrastText(color),
|
|
78
|
+
name: it.node.name, value: it.value, depth,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
offset += cellSize
|
|
82
|
+
}
|
|
83
|
+
// Shrink the remaining strip.
|
|
84
|
+
if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
|
|
85
|
+
remaining -= rowSum
|
|
86
|
+
row = []
|
|
87
|
+
}
|
|
88
|
+
for (const it of ordered) {
|
|
89
|
+
const scale = (cw * ch) / remaining
|
|
90
|
+
const shortSide = Math.min(cw, ch)
|
|
91
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
92
|
+
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
|
|
93
|
+
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
|
|
94
|
+
if (row.length && nextWorst > currWorst) {
|
|
95
|
+
flushRow()
|
|
96
|
+
}
|
|
97
|
+
row.push(it)
|
|
98
|
+
}
|
|
99
|
+
flushRow()
|
|
100
|
+
}
|
|
101
|
+
const seedItems = root.children ?? [root]
|
|
102
|
+
squarify(seedItems, padL, padT, plotW, plotH, 0)
|
|
103
|
+
return { ...empty, plot, treemapCells: cells }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The subtree at `path` (a list of node names from the root's children down),
|
|
108
|
+
* or the tree itself for an empty path. What a drilldown shows after each
|
|
109
|
+
* click: the clicked node becomes the root. Null when the path does not exist.
|
|
110
|
+
*/
|
|
111
|
+
export function drillTree(tree: TreeNode, path: ReadonlyArray<string>): TreeNode | null {
|
|
112
|
+
let node: TreeNode | undefined = tree
|
|
113
|
+
for (const name of path) {
|
|
114
|
+
node = node?.children?.find((c) => c.name === name)
|
|
115
|
+
if (!node) return null
|
|
116
|
+
}
|
|
117
|
+
return node ?? null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The path (node names from the root's children) to the first node called
|
|
122
|
+
* `name`, depth first, or null. What a click on a leaf's label needs.
|
|
123
|
+
*/
|
|
124
|
+
export function pathTo(tree: TreeNode, name: string): string[] | null {
|
|
125
|
+
const walk = (n: TreeNode, acc: string[]): string[] | null => {
|
|
126
|
+
for (const c of n.children ?? []) {
|
|
127
|
+
const next = [...acc, c.name]
|
|
128
|
+
if (c.name === name) return next
|
|
129
|
+
const deeper = walk(c, next)
|
|
130
|
+
if (deeper) return deeper
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
return walk(tree, [])
|
|
135
|
+
}
|