basalt-ui 1.30.2 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +54 -0
- package/MIGRATING.md +256 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +5 -5
- package/configs/oxlint-plugin.js +371 -4
- package/configs/oxlint-plugin.test.ts +327 -2
- package/configs/oxlint.json +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +41 -43
- package/dist/agent-chat/thread-workspace.d.ts +2 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +3 -4
- package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
- package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
- package/dist/agent-chat/use-keyboard-inset.js +34 -0
- package/dist/charts/cursor/touch-pin.d.ts +45 -0
- package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
- package/dist/charts/cursor/touch-pin.js +79 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
- package/dist/charts/hooks/use-discrete-cursor.js +198 -0
- package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
- package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
- package/dist/charts/hooks/use-hysteresis.js +12 -0
- package/dist/charts/hooks/useBandPlot.d.ts +7 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
- package/dist/charts/hooks/useBandPlot.js +62 -18
- package/dist/charts/hooks/useChartCursor.d.ts +40 -11
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +102 -11
- package/dist/charts/index.d.ts +0 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +0 -10
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +47 -82
- package/dist/charts/kinds/Donut.d.ts +9 -3
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +110 -48
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +138 -145
- package/dist/charts/kinds/Heatmap.d.ts +7 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +37 -16
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +68 -111
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/kinds/donut-layout.d.ts +47 -0
- package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
- package/dist/charts/kinds/donut-layout.js +52 -0
- package/dist/charts/layout/auto-margin.d.ts +7 -2
- package/dist/charts/layout/auto-margin.d.ts.map +1 -1
- package/dist/charts/layout/auto-margin.js +12 -3
- package/dist/charts/layout/end-labels.d.ts +64 -0
- package/dist/charts/layout/end-labels.d.ts.map +1 -0
- package/dist/charts/layout/end-labels.js +76 -0
- package/dist/charts/primitives/Axes.d.ts +32 -4
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +62 -16
- package/dist/charts/primitives/CartesianChart.d.ts +7 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +264 -145
- package/dist/charts/primitives/ChartCard.d.ts +0 -14
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +56 -21
- package/dist/charts/primitives/ChartFrame.d.ts +28 -22
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +116 -34
- package/dist/charts/primitives/ChartLegend.d.ts +19 -15
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +86 -73
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +20 -4
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +54 -27
- package/dist/charts/primitives/HoverOverlay.module.css +13 -0
- package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/dist/charts/primitives/chart-card-context.d.ts +33 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +18 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +75 -34
- package/dist/charts/primitives/chart-layout.d.ts +112 -0
- package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
- package/dist/charts/primitives/chart-layout.js +175 -0
- package/dist/charts/primitives/chart-tier.d.ts +13 -11
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +42 -13
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/charts/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/ticks.d.ts +27 -8
- package/dist/charts/utils/ticks.d.ts.map +1 -1
- package/dist/charts/utils/ticks.js +35 -11
- package/dist/common/errors.d.ts +11 -1
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +16 -2
- package/dist/common/hit-floor.d.ts +10 -0
- package/dist/common/hit-floor.d.ts.map +1 -0
- package/dist/common/hit-floor.js +6 -0
- package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
- package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/common/use-measured-widths.d.ts +37 -0
- package/dist/common/use-measured-widths.d.ts.map +1 -0
- package/dist/common/use-measured-widths.js +51 -0
- package/dist/common/use-media-query.d.ts +6 -0
- package/dist/common/use-media-query.d.ts.map +1 -0
- package/dist/common/use-media-query.js +40 -0
- package/dist/common/validate.d.ts +9 -0
- package/dist/common/validate.d.ts.map +1 -1
- package/dist/common/validate.js +9 -4
- package/dist/content/article-layout.d.ts +4 -2
- package/dist/content/article-layout.d.ts.map +1 -1
- package/dist/content/article-layout.js +43 -3
- package/dist/content/article-layout.module.css +45 -4
- package/dist/content/article-layout.module.css.d.ts +3 -0
- package/dist/content/code-block.d.ts.map +1 -1
- package/dist/content/code-block.js +40 -2
- package/dist/content/code-block.module.css +32 -2
- package/dist/content/prose.module.css +13 -8
- package/dist/controls/actions.d.ts +29 -6
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +158 -27
- package/dist/controls/actions.module.css +14 -1
- package/dist/controls/actions.module.css.d.ts +1 -0
- package/dist/controls/controls.module.css +13 -31
- package/dist/controls/enum-filter.js +2 -2
- package/dist/controls/filter-context.d.ts +10 -0
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/filter-context.js +2 -0
- package/dist/controls/filter-pill.d.ts.map +1 -1
- package/dist/controls/filter-pill.js +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +0 -2
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/sync-button.module.css +2 -1
- package/dist/dashboard/section.d.ts.map +1 -1
- package/dist/dashboard/section.js +1 -0
- package/dist/dashboard/section.module.css +1 -26
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +4 -1
- package/dist/dashboard/stat-card.module.css +6 -9
- package/dist/dashboard/stat-group.module.css +33 -66
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/dashboard/widget-header.d.ts +14 -4
- package/dist/dashboard/widget-header.d.ts.map +1 -1
- package/dist/dashboard/widget-header.js +23 -6
- package/dist/dashboard/widget-header.module.css +145 -133
- package/dist/dashboard/widget-header.module.css.d.ts +2 -0
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +56 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +436 -336
- package/dist/data/data-table.module.css +94 -0
- package/dist/data/data-table.module.css.d.ts +11 -0
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual-list.js +10 -0
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +3 -3
- package/dist/forms/form-layout.module.css +20 -5
- package/dist/forms/form-layout.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +8 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +68 -2
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +30 -8
- package/dist/shell/app-brand.d.ts.map +1 -1
- package/dist/shell/app-brand.js +1 -0
- package/dist/shell/app-brand.module.css +2 -2
- package/dist/shell/app-header.module.css +18 -8
- package/dist/shell/app-main.module.css +54 -3
- package/dist/shell/app-main.module.css.d.ts +1 -0
- package/dist/shell/app-mobile-nav.module.css +16 -4
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +1 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +56 -16
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/page-aside.d.ts +22 -3
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +27 -13
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +10 -5
- package/dist/shell/page-bar.module.css +10 -34
- package/dist/shell/use-breakpoint.d.ts +3 -4
- package/dist/shell/use-breakpoint.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.js +3 -25
- package/dist/shell/use-size-class.d.ts +6 -0
- package/dist/shell/use-size-class.d.ts.map +1 -0
- package/dist/shell/use-size-class.js +19 -0
- package/dist/state/persisted-or-local.d.ts +4 -1
- package/dist/state/persisted-or-local.d.ts.map +1 -1
- package/dist/state/persisted-or-local.js +4 -3
- package/dist/state/persisted.d.ts +10 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +44 -14
- package/dist/styles.css +41 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +11 -3
- package/dist/theme/controls.module.css +7 -0
- package/dist/theme/ctl-theme.d.ts +1 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -1
- package/dist/theme/ctl-theme.js +12 -4
- package/dist/theme/floating.module.css +37 -0
- package/dist/theme/floating.module.css.d.ts +3 -0
- package/dist/theme/index.d.ts +2 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +34 -5
- package/dist/theme/nav-link.module.css +7 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +7 -16
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +23 -28
- package/dist/tokens/palette.d.ts +5 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +8 -30
- package/dist/tokens/size-classes.d.ts +40 -0
- package/dist/tokens/size-classes.d.ts.map +1 -0
- package/dist/tokens/size-classes.js +29 -0
- package/dist/tokens.css +8 -4
- package/llms.txt +3 -3
- package/package.json +18 -17
- package/src/agent-chat/composer.tsx +74 -47
- package/src/agent-chat/thread-workspace.tsx +8 -11
- package/src/agent-chat/use-keyboard-inset.ts +68 -0
- package/src/charts/cursor/touch-pin.ts +138 -0
- package/src/charts/hooks/use-discrete-cursor.ts +335 -0
- package/src/charts/hooks/use-hysteresis.ts +26 -0
- package/src/charts/hooks/useBandPlot.ts +90 -27
- package/src/charts/hooks/useChartCursor.ts +211 -26
- package/src/charts/index.ts +0 -8
- package/src/charts/kinds/BandStrip.tsx +25 -61
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +21 -26
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +23 -65
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/kinds/donut-layout.ts +82 -0
- package/src/charts/layout/auto-margin.ts +24 -5
- package/src/charts/layout/end-labels.ts +135 -0
- package/src/charts/primitives/Axes.tsx +112 -21
- package/src/charts/primitives/CartesianChart.tsx +282 -111
- package/src/charts/primitives/ChartCard.tsx +119 -48
- package/src/charts/primitives/ChartFrame.tsx +204 -60
- package/src/charts/primitives/ChartLegend.tsx +115 -78
- package/src/charts/primitives/ChartTooltip.tsx +24 -3
- package/src/charts/primitives/Crosshair.tsx +2 -2
- package/src/charts/primitives/HoverOverlay.module.css +13 -0
- package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/src/charts/primitives/HoverOverlay.tsx +62 -20
- package/src/charts/primitives/chart-card-context.ts +46 -0
- package/src/charts/primitives/chart-frame-layout.ts +207 -182
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/charts/series.ts +4 -1
- package/src/charts/utils/ticks.ts +95 -19
- package/src/common/errors.ts +25 -1
- package/src/common/hit-floor.ts +9 -0
- package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
- package/src/common/use-measured-widths.ts +88 -0
- package/src/common/use-media-query.ts +56 -0
- package/src/common/validate.ts +17 -4
- package/src/content/article-layout.module.css +45 -4
- package/src/content/article-layout.module.css.d.ts +3 -0
- package/src/content/article-layout.tsx +60 -5
- package/src/content/code-block.module.css +32 -2
- package/src/content/code-block.tsx +47 -3
- package/src/content/prose.module.css +13 -8
- package/src/controls/actions.module.css +14 -1
- package/src/controls/actions.module.css.d.ts +1 -0
- package/src/controls/actions.tsx +223 -75
- package/src/controls/controls.module.css +13 -31
- package/src/controls/enum-filter.tsx +2 -2
- package/src/controls/filter-context.tsx +11 -0
- package/src/controls/filter-pill.tsx +1 -0
- package/src/controls/index.ts +0 -1
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/sync-button.module.css +2 -1
- package/src/dashboard/section.module.css +1 -26
- package/src/dashboard/section.tsx +1 -0
- package/src/dashboard/stat-card.module.css +6 -9
- package/src/dashboard/stat-card.tsx +6 -3
- package/src/dashboard/stat-group.module.css +33 -66
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/dashboard/widget-grid.tsx +7 -6
- package/src/dashboard/widget-header.module.css +145 -133
- package/src/dashboard/widget-header.module.css.d.ts +2 -0
- package/src/dashboard/widget-header.tsx +45 -11
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +547 -299
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +20 -5
- package/src/forms/form-layout.module.css.d.ts +1 -0
- package/src/forms/form-layout.tsx +12 -10
- package/src/guard/index.ts +188 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +60 -7
- package/src/shell/app-brand.module.css +2 -2
- package/src/shell/app-brand.tsx +1 -0
- package/src/shell/app-header.module.css +18 -8
- package/src/shell/app-main.module.css +54 -3
- package/src/shell/app-main.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.module.css +16 -4
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +178 -129
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +70 -21
- package/src/shell/page-bar.module.css +10 -34
- package/src/shell/page-bar.tsx +10 -5
- package/src/shell/use-breakpoint.ts +9 -45
- package/src/shell/use-size-class.ts +31 -0
- package/src/state/persisted-or-local.ts +10 -4
- package/src/state/persisted.ts +76 -17
- package/src/styles.css +41 -0
- package/src/surfaces.ts +9 -1
- package/src/theme/controls.module.css +7 -0
- package/src/theme/ctl-theme.tsx +15 -3
- package/src/theme/floating.module.css +37 -0
- package/src/theme/floating.module.css.d.ts +3 -0
- package/src/theme/index.ts +46 -8
- package/src/theme/nav-link.module.css +7 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
- package/src/tokens/index.ts +20 -30
- package/src/tokens/palette.ts +6 -28
- package/src/tokens/size-classes.ts +46 -0
- package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
- /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Group } from '@visx/group'
|
|
2
|
-
import type {
|
|
3
|
-
import { memo, useMemo
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { memo, useMemo } from 'react'
|
|
4
4
|
import { assertRequiredProps } from '../../common/validate'
|
|
5
5
|
import type { BasaltProps } from '../../common/props'
|
|
6
6
|
import {
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
} from '../primitives/ChartTooltip'
|
|
12
12
|
import { ChartFrame } from '../primitives/ChartFrame'
|
|
13
13
|
import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
|
|
14
|
-
import {
|
|
14
|
+
import { useChartMetrics } from '../primitives/chart-tier'
|
|
15
|
+
import { useDiscreteCursor } from '../hooks/use-discrete-cursor'
|
|
15
16
|
import type { ChartState } from '../primitives/ChartPending'
|
|
16
17
|
import { maxTextWidth } from '../utils/measure-text'
|
|
17
18
|
import { thinLabels, xLabelPxFor } from '../utils/ticks'
|
|
@@ -20,8 +21,9 @@ import { VX, alpha } from '../../tokens'
|
|
|
20
21
|
/** A single resolved heatmap cell — the unit the tooltip and hover operate on. */
|
|
21
22
|
type HeatmapCell = { row: string; col: string; value: number }
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/** One grid position, data or not — `useDiscreteCursor`'s target unit. `value` is `undefined` for
|
|
25
|
+
* an (row, col) pair with no data, which stays a keyboard stop but never shows a tooltip. */
|
|
26
|
+
type CellTarget = { row: string; col: string; value: number | undefined }
|
|
25
27
|
|
|
26
28
|
export type HeatmapProps<T> = BasaltProps & {
|
|
27
29
|
data: T[]
|
|
@@ -115,6 +117,13 @@ const LEGEND_LABEL_H = 16
|
|
|
115
117
|
* right gutter reserves half the widest column label, so no two painted labels overlap and the
|
|
116
118
|
* last one cannot clip.
|
|
117
119
|
*
|
|
120
|
+
* Tap-to-pin, keyboard stepping and focus over the grid come from `useDiscreteCursor`
|
|
121
|
+
* (`docs/CHARTS-SPEC.md` §4, Tooltip), the discrete counterpart of `HoverOverlay`. Its targets are
|
|
122
|
+
* the FULL row-major grid (data cells and empty ones alike) so Up/Down can jump one row via a fixed
|
|
123
|
+
* `columns` stride; only a cell WITH data renders a tooltip or takes pointer input, matching today's
|
|
124
|
+
* hover behavior — an empty cell is still a keyboard stop (so the grid reads as one rectangle), it
|
|
125
|
+
* just carries no readout.
|
|
126
|
+
*
|
|
118
127
|
* Composes `ChartFrame` purely for measuring (`height`/`aspectRatio`/`fill`, the same three
|
|
119
128
|
* sizing modes every other kind exposes) — `legend={false}` and an empty `series` opt out of
|
|
120
129
|
* `ChartFrame`'s own derived legend, since Heatmap already ships its own gradient strip.
|
|
@@ -177,9 +186,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
|
|
|
177
186
|
// The row/column labels and the gradient legend's endpoints read like axis ticks, so they take
|
|
178
187
|
// the tier's tick font for the same reason `Axes.tsx` does — a 360px heatmap that painted its
|
|
179
188
|
// categories at the desktop size would be the one chart still ignoring §8.
|
|
180
|
-
const { axisFont } =
|
|
181
|
-
|
|
182
|
-
const [tip, setTip] = useState<HeatmapTip | null>(null)
|
|
189
|
+
const { axisFont } = useChartMetrics()
|
|
183
190
|
|
|
184
191
|
const rows = useMemo(() => rowsProp ?? firstSeen(data, getRow), [rowsProp, data, getRow])
|
|
185
192
|
const cols = useMemo(() => colsProp ?? firstSeen(data, getCol), [colsProp, data, getCol])
|
|
@@ -196,6 +203,36 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
|
|
|
196
203
|
return { lookup: map, max: m }
|
|
197
204
|
}, [data, getRow, getCol, getValue])
|
|
198
205
|
|
|
206
|
+
// The FULL row-major grid (gaps included) so `columns: cols.length` gives `useDiscreteCursor` a
|
|
207
|
+
// real row stride for Up/Down — a targets list built only from cells WITH data would shift that
|
|
208
|
+
// stride the moment a row has a gap.
|
|
209
|
+
const cellTargets = useMemo<CellTarget[]>(() => {
|
|
210
|
+
const out: CellTarget[] = []
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
for (const col of cols) out.push({ row, col, value: lookup.get(cellKey(row, col)) })
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}, [rows, cols, lookup])
|
|
216
|
+
const cursor = useDiscreteCursor<CellTarget>({
|
|
217
|
+
targets: cellTargets,
|
|
218
|
+
getKey: (c) => cellKey(c.row, c.col),
|
|
219
|
+
columns: cols.length,
|
|
220
|
+
ariaLabel: 'Heatmap cells — use arrow keys to navigate, Escape to dismiss',
|
|
221
|
+
})
|
|
222
|
+
// Resolved once here (not inline in the JSX below) so `value` narrows to `number` — a keyboard
|
|
223
|
+
// stop on an empty cell has a `tip` but no readout to show.
|
|
224
|
+
const activeTip =
|
|
225
|
+
cursor.tip !== null && cursor.tip.target.value !== undefined
|
|
226
|
+
? {
|
|
227
|
+
anchor: cursor.tip.anchor,
|
|
228
|
+
cell: {
|
|
229
|
+
row: cursor.tip.target.row,
|
|
230
|
+
col: cursor.tip.target.col,
|
|
231
|
+
value: cursor.tip.target.value,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: null
|
|
235
|
+
|
|
199
236
|
const rowLabels = useMemo(() => rows.map(rowLabel), [rows, rowLabel])
|
|
200
237
|
const colLabels = useMemo(() => cols.map(colLabel), [cols, colLabel])
|
|
201
238
|
|
|
@@ -223,23 +260,27 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
|
|
|
223
260
|
[rowLabels, cellH, axisFont],
|
|
224
261
|
)
|
|
225
262
|
|
|
226
|
-
const show = (row: string, col: string, value: number, event: PointerEvent<SVGRectElement>) => {
|
|
227
|
-
setTip({ row, col, value, anchor: { x: event.clientX, y: event.clientY } })
|
|
228
|
-
}
|
|
229
|
-
const hide = () => setTip(null)
|
|
230
|
-
|
|
231
263
|
return (
|
|
232
264
|
<div style={{ position: 'relative' }}>
|
|
233
|
-
<svg width={width} height={height}>
|
|
265
|
+
<svg width={width} height={height} {...cursor.hostProps}>
|
|
234
266
|
<Group left={PAD_LEFT} top={PAD_TOP}>
|
|
235
267
|
{rows.flatMap((row, ri) =>
|
|
236
268
|
cols.map((col, ci) => {
|
|
237
269
|
const value = lookup.get(cellKey(row, col))
|
|
238
270
|
const has = value !== undefined
|
|
239
271
|
const cellFill = has ? alpha(color, intensity(value, max)) : alpha(VX.neutral, 0.04)
|
|
272
|
+
const target: CellTarget = { row, col, value }
|
|
273
|
+
const isActive = cursor.tip?.target.row === row && cursor.tip?.target.col === col
|
|
240
274
|
return (
|
|
241
275
|
<rect
|
|
242
276
|
key={cellKey(row, col)}
|
|
277
|
+
id={cursor.optionId(target)}
|
|
278
|
+
// An SVG `<rect>` has no `<option>` counterpart — `useDiscreteCursor`'s
|
|
279
|
+
// listbox/option pair (see its JSDoc) is a role over an arbitrary shape.
|
|
280
|
+
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
|
|
281
|
+
role="option"
|
|
282
|
+
aria-selected={isActive}
|
|
283
|
+
aria-label={`${rowLabels[ri]}: ${colLabels[ci]}: ${value !== undefined ? formatValue(value) : 'no data'}`}
|
|
243
284
|
x={ci * cellW + cellGap / 2}
|
|
244
285
|
y={ri * cellH + cellGap / 2}
|
|
245
286
|
width={Math.max(0, cellW - cellGap)}
|
|
@@ -247,9 +288,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
|
|
|
247
288
|
rx={cellRadius}
|
|
248
289
|
fill={cellFill}
|
|
249
290
|
style={{ cursor: has ? 'pointer' : 'default' }}
|
|
250
|
-
|
|
251
|
-
onPointerLeave={hide}
|
|
252
|
-
onPointerCancel={hide}
|
|
291
|
+
{...(has ? cursor.pointerProps(target) : {})}
|
|
253
292
|
/>
|
|
254
293
|
)
|
|
255
294
|
}),
|
|
@@ -328,17 +367,17 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
|
|
|
328
367
|
)}
|
|
329
368
|
</svg>
|
|
330
369
|
|
|
331
|
-
{
|
|
332
|
-
<ChartTooltipFloat anchor={
|
|
333
|
-
<TooltipHeader date={rowLabel(
|
|
370
|
+
{activeTip !== null && (
|
|
371
|
+
<ChartTooltipFloat anchor={activeTip.anchor}>
|
|
372
|
+
<TooltipHeader date={rowLabel(activeTip.cell.row)} label={colLabel(activeTip.cell.col)} />
|
|
334
373
|
<TooltipBody>
|
|
335
374
|
<TooltipRow
|
|
336
375
|
color={alpha(color, 0.9)}
|
|
337
376
|
shape="bar"
|
|
338
377
|
label="Value"
|
|
339
|
-
value={formatValue(
|
|
378
|
+
value={formatValue(activeTip.cell.value)}
|
|
340
379
|
/>
|
|
341
|
-
{renderTooltip?.(
|
|
380
|
+
{renderTooltip?.(activeTip.cell)}
|
|
342
381
|
</TooltipBody>
|
|
343
382
|
</ChartTooltipFloat>
|
|
344
383
|
)}
|
|
@@ -10,22 +10,17 @@ import type { CursorResolution } from '../cursor/resolve'
|
|
|
10
10
|
import { useBandPlot } from '../hooks/useBandPlot'
|
|
11
11
|
import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
|
|
12
12
|
import { probeAxisLabels } from '../layout/auto-margin'
|
|
13
|
-
import {
|
|
13
|
+
import { AxisLeftNumeric } from '../primitives/Axes'
|
|
14
14
|
import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
|
|
15
15
|
import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
|
|
16
|
-
import {
|
|
17
|
-
ChartTooltipFloat,
|
|
18
|
-
TooltipBody,
|
|
19
|
-
TooltipHeader,
|
|
20
|
-
TooltipRow,
|
|
21
|
-
} from '../primitives/ChartTooltip'
|
|
16
|
+
import { TooltipRows } from '../primitives/ChartTooltip'
|
|
22
17
|
import { Crosshair } from '../primitives/Crosshair'
|
|
23
18
|
import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
|
|
24
|
-
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
25
19
|
import type { ChartState } from '../primitives/ChartPending'
|
|
26
20
|
import { deriveTooltipRows } from '../series'
|
|
27
21
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
28
22
|
import { fmtAxisDate } from '../utils/format'
|
|
23
|
+
import { BandAxisOverlay, BandTooltip, clampFraction } from './band-chrome'
|
|
29
24
|
|
|
30
25
|
/** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
|
|
31
26
|
export type MirroredBarPane = {
|
|
@@ -242,7 +237,7 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
242
237
|
...(tooltip !== undefined && { tooltip }),
|
|
243
238
|
})
|
|
244
239
|
|
|
245
|
-
const { bands, margin, plotWidth,
|
|
240
|
+
const { bands, margin, plotWidth, point, crosshairX, step, bandWidth } = band
|
|
246
241
|
|
|
247
242
|
// ── Pass 2: the pane rects, now that the plot height is known ───────────────────────────────
|
|
248
243
|
const barBand = Math.max(plot.height - margin.top - margin.bottom, 2)
|
|
@@ -346,8 +341,6 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
346
341
|
|
|
347
342
|
if (plotWidth <= 0 || bands.length === 0) return null
|
|
348
343
|
|
|
349
|
-
const cfg = tooltip === false ? undefined : tooltip
|
|
350
|
-
const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
|
|
351
344
|
// Each pane's row falls back to ITS OWN axis formatter, never the other pane's — the two panes
|
|
352
345
|
// are in different units by construction (an explicit per-series `formatValue` still wins).
|
|
353
346
|
const visibleSeries = series
|
|
@@ -362,7 +355,13 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
362
355
|
|
|
363
356
|
return (
|
|
364
357
|
<>
|
|
365
|
-
<svg
|
|
358
|
+
<svg
|
|
359
|
+
ref={band.svgRef}
|
|
360
|
+
width={plot.width}
|
|
361
|
+
height={plot.height}
|
|
362
|
+
// `touch-action` on the plot `<svg>`: Chrome ignores it on the overlay `<rect>` (`R2C-2`).
|
|
363
|
+
style={{ touchAction: 'pan-y' }}
|
|
364
|
+
>
|
|
366
365
|
<defs>
|
|
367
366
|
<HatchPattern
|
|
368
367
|
id={hatchId}
|
|
@@ -383,19 +382,21 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
383
382
|
|
|
384
383
|
{/* One axis per pane, each in its own scale's units — a single shared axis would be
|
|
385
384
|
wrong for at least one of them whenever the two domains differ, which is always. */}
|
|
386
|
-
{upVisible && (
|
|
385
|
+
{upVisible && band.yPlacement !== 'none' && (
|
|
387
386
|
<AxisLeftNumeric
|
|
388
387
|
scale={upAxisScale}
|
|
389
388
|
numTicks={upTicks}
|
|
390
389
|
tickFormat={(v) => up.format(Number(v))}
|
|
390
|
+
inside={band.yPlacement === 'inside'}
|
|
391
391
|
/>
|
|
392
392
|
)}
|
|
393
|
-
{downVisible && (
|
|
393
|
+
{downVisible && band.yPlacement !== 'none' && (
|
|
394
394
|
<Group top={baseline}>
|
|
395
395
|
<AxisLeftNumeric
|
|
396
396
|
scale={downScale}
|
|
397
397
|
numTicks={downTicks}
|
|
398
398
|
tickFormat={(v) => down.format(Number(v))}
|
|
399
|
+
inside={band.yPlacement === 'inside'}
|
|
399
400
|
/>
|
|
400
401
|
</Group>
|
|
401
402
|
)}
|
|
@@ -411,66 +412,23 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
411
412
|
|
|
412
413
|
{crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={barBand} />}
|
|
413
414
|
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
scale={scale}
|
|
417
|
-
tickValues={band.tickValues}
|
|
418
|
-
tickFormat={(v) => formatX(String(v))}
|
|
419
|
-
/>
|
|
420
|
-
|
|
421
|
-
<HoverOverlay
|
|
422
|
-
width={plotWidth}
|
|
415
|
+
<BandAxisOverlay
|
|
416
|
+
band={band}
|
|
423
417
|
height={barBand}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
onBlur={cursor.onBlur}
|
|
428
|
-
valueMax={Math.max(bands.length - 1, 0)}
|
|
429
|
-
{...(point !== null && {
|
|
430
|
-
valueNow: bands.indexOf(point),
|
|
431
|
-
valueText: formatX(getX(point)),
|
|
432
|
-
})}
|
|
433
|
-
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
418
|
+
getX={getX}
|
|
419
|
+
formatX={formatX}
|
|
420
|
+
ariaLabel={ariaLabel}
|
|
434
421
|
/>
|
|
435
422
|
</Group>
|
|
436
423
|
</svg>
|
|
437
424
|
|
|
438
|
-
{band
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
date={getX(point)}
|
|
442
|
-
{...(cfg?.formatHeader !== undefined && {
|
|
443
|
-
format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
|
|
444
|
-
})}
|
|
445
|
-
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
446
|
-
/>
|
|
447
|
-
<TooltipBody>
|
|
448
|
-
{cfg?.prependRows?.(point, { hidden })}
|
|
449
|
-
{rows.map((row) => (
|
|
450
|
-
<TooltipRow
|
|
451
|
-
key={row.key}
|
|
452
|
-
color={row.color}
|
|
453
|
-
label={row.label}
|
|
454
|
-
value={row.value}
|
|
455
|
-
shape={row.shape}
|
|
456
|
-
dashed={row.dashed}
|
|
457
|
-
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
458
|
-
/>
|
|
459
|
-
))}
|
|
460
|
-
{cfg?.extraRows?.(point, { hidden })}
|
|
461
|
-
</TooltipBody>
|
|
462
|
-
</ChartTooltipFloat>
|
|
463
|
-
)}
|
|
425
|
+
<BandTooltip band={band} tooltip={tooltip} getX={getX} hidden={hidden}>
|
|
426
|
+
<TooltipRows rows={rows} />
|
|
427
|
+
</BandTooltip>
|
|
464
428
|
</>
|
|
465
429
|
)
|
|
466
430
|
}
|
|
467
431
|
|
|
468
|
-
/** A 0..1 share, non-finite included — see `BandStrip`'s identical guard. */
|
|
469
|
-
function clampFraction(value: number | undefined): number {
|
|
470
|
-
if (value === undefined || !Number.isFinite(value)) return 0
|
|
471
|
-
return Math.min(Math.max(value, 0), 1)
|
|
472
|
-
}
|
|
473
|
-
|
|
474
432
|
/** A drawable value, or null. See the call site for why NaN is not drawable. */
|
|
475
433
|
function finiteOrNull(v: number | null | undefined): number | null {
|
|
476
434
|
return v === null || v === undefined || Number.isNaN(v) ? null : v
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { BandPlot, BandTooltipConfig } from '../hooks/useBandPlot'
|
|
3
|
+
import { AxisBottomDate } from '../primitives/Axes'
|
|
4
|
+
import { ChartTooltipFloat, TooltipBody, TooltipHeader } from '../primitives/ChartTooltip'
|
|
5
|
+
import { cursorSliderProps, HoverOverlay } from '../primitives/HoverOverlay'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The chrome `BandStrip` and `MirroredBars` share around their own marks — the slot axis, the
|
|
9
|
+
* keyboard/pointer overlay and the tooltip shell — so the two `useBandPlot` kinds cannot drift in
|
|
10
|
+
* any of them. Internal; neither is exported from a barrel.
|
|
11
|
+
*
|
|
12
|
+
* theme-allow-file hand-rolled-plot — the slot axis and overlay of the two declared banded
|
|
13
|
+
* exceptions (`BandStrip`, `MirroredBars`), which carry the same waiver for the same reason.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
|
|
17
|
+
* end, since the alternative is a band that does not render at all. */
|
|
18
|
+
export function clampFraction(value: number | undefined): number {
|
|
19
|
+
if (value === undefined || !Number.isFinite(value)) return 0
|
|
20
|
+
return Math.min(Math.max(value, 0), 1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The slot axis under a `height`-tall band row, plus the hover/keyboard overlay over it. */
|
|
24
|
+
export function BandAxisOverlay<T>({
|
|
25
|
+
band,
|
|
26
|
+
height,
|
|
27
|
+
getX,
|
|
28
|
+
formatX,
|
|
29
|
+
ariaLabel,
|
|
30
|
+
}: {
|
|
31
|
+
band: BandPlot<T>
|
|
32
|
+
height: number
|
|
33
|
+
getX: (d: T) => string
|
|
34
|
+
formatX: (key: string) => string
|
|
35
|
+
ariaLabel: string | undefined
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<AxisBottomDate
|
|
40
|
+
top={height}
|
|
41
|
+
scale={band.scale}
|
|
42
|
+
tickValues={band.tickValues}
|
|
43
|
+
tickFormat={(v) => formatX(String(v))}
|
|
44
|
+
anchorTerminals={band.xAnchorTerminals}
|
|
45
|
+
{...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
|
|
46
|
+
/>
|
|
47
|
+
<HoverOverlay
|
|
48
|
+
width={band.plotWidth}
|
|
49
|
+
height={height}
|
|
50
|
+
{...cursorSliderProps({ cursor: band.cursor, data: band.bands, getX, formatX, ariaLabel })}
|
|
51
|
+
/>
|
|
52
|
+
</>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The tooltip shell: header (+ badge), the consumer's prepended rows, the kind's own derived
|
|
57
|
+
* `children` rows, the consumer's extra rows. Renders nothing off-hover or with `tooltip: false`. */
|
|
58
|
+
export function BandTooltip<T>({
|
|
59
|
+
band,
|
|
60
|
+
tooltip,
|
|
61
|
+
getX,
|
|
62
|
+
hidden,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
band: BandPlot<T>
|
|
66
|
+
tooltip: BandTooltipConfig<T> | false | undefined
|
|
67
|
+
getX: (d: T) => string
|
|
68
|
+
hidden: ReadonlySet<string>
|
|
69
|
+
children: ReactNode
|
|
70
|
+
}) {
|
|
71
|
+
const { point } = band
|
|
72
|
+
if (!band.showTooltip || point === null) return null
|
|
73
|
+
const cfg = tooltip === false ? undefined : tooltip
|
|
74
|
+
const badge = cfg?.label === undefined ? null : cfg.label(point)
|
|
75
|
+
return (
|
|
76
|
+
<ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
|
|
77
|
+
<TooltipHeader
|
|
78
|
+
date={getX(point)}
|
|
79
|
+
{...(cfg?.formatHeader !== undefined && {
|
|
80
|
+
format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
|
|
81
|
+
})}
|
|
82
|
+
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
83
|
+
/>
|
|
84
|
+
<TooltipBody>
|
|
85
|
+
{cfg?.prependRows?.(point, { hidden })}
|
|
86
|
+
{children}
|
|
87
|
+
{cfg?.extraRows?.(point, { hidden })}
|
|
88
|
+
</TooltipBody>
|
|
89
|
+
</ChartTooltipFloat>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ContainerClass } from '../../tokens/size-classes'
|
|
2
|
+
|
|
3
|
+
/** Aspect (frame W/H) above which the legend moves beside the ring. */
|
|
4
|
+
export const SIDE_LEGEND_ASPECT = 1.5
|
|
5
|
+
/** Aspect a side layout must fall below before it stacks again — the gap stops a legend whose own
|
|
6
|
+
* placement changes the measured height from flipping the layout back and forth. */
|
|
7
|
+
export const STACK_LEGEND_ASPECT = 1.35
|
|
8
|
+
/** Most slices kept before the rest fold into "Other" (so 7 or more slices fold). */
|
|
9
|
+
export const DONUT_MAX_SLICES = 6
|
|
10
|
+
export const OTHER_KEY = '__other'
|
|
11
|
+
/** Side layout: the ring's diameter never exceeds this share of the frame width. */
|
|
12
|
+
export const SIDE_RING_MAX_WIDTH_SHARE = 0.55
|
|
13
|
+
/** Space kept between the ring's outer edge and the plot box. */
|
|
14
|
+
export const RING_PADDING = 4
|
|
15
|
+
|
|
16
|
+
export type DonutLayout = { side: boolean }
|
|
17
|
+
|
|
18
|
+
/** Side legend only on a wide-enough frame (`micro`/`compact` always stack under the ring).
|
|
19
|
+
* `wasSide` applies hysteresis: an already-side layout stays side until W/H falls below
|
|
20
|
+
* {@link STACK_LEGEND_ASPECT}. */
|
|
21
|
+
export function resolveDonutLayout(input: {
|
|
22
|
+
frameW: number
|
|
23
|
+
frameH: number
|
|
24
|
+
containerClass: ContainerClass
|
|
25
|
+
wasSide?: boolean
|
|
26
|
+
}): DonutLayout {
|
|
27
|
+
const { frameW, frameH, containerClass, wasSide = false } = input
|
|
28
|
+
const roomy = containerClass !== 'micro' && containerClass !== 'compact'
|
|
29
|
+
if (!roomy || frameH <= 0) return { side: false }
|
|
30
|
+
const aspect = frameW / frameH
|
|
31
|
+
return { side: wasSide ? aspect >= STACK_LEGEND_ASPECT : aspect > SIDE_LEGEND_ASPECT }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ring outer radius: never negative and never wider than the plot; the side layout also caps the
|
|
35
|
+
* diameter at {@link SIDE_RING_MAX_WIDTH_SHARE} of the frame width. */
|
|
36
|
+
export function resolveDonutRadius(input: {
|
|
37
|
+
plotW: number
|
|
38
|
+
plotH: number
|
|
39
|
+
frameW: number
|
|
40
|
+
side: boolean
|
|
41
|
+
}): number {
|
|
42
|
+
const { plotW, plotH, frameW, side } = input
|
|
43
|
+
const diameter = side
|
|
44
|
+
? Math.min(plotW, plotH, SIDE_RING_MAX_WIDTH_SHARE * frameW)
|
|
45
|
+
: Math.min(plotW, plotH)
|
|
46
|
+
return Math.max(0, diameter / 2 - RING_PADDING)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Share of `total` as a whole percent; an empty total is 0%. */
|
|
50
|
+
export function sharePercent(value: number, total: number): number {
|
|
51
|
+
return total > 0 ? Math.round((value / total) * 100) : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A slice value a ring can draw: non-finite or negative counts as 0. */
|
|
55
|
+
const safeValue = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
56
|
+
|
|
57
|
+
/** The key for the synthesized "Other" bucket — {@link OTHER_KEY}, prefixed until no input key uses it. */
|
|
58
|
+
export function resolveOtherKey(keys: readonly string[]): string {
|
|
59
|
+
const taken = new Set(keys)
|
|
60
|
+
let key = OTHER_KEY
|
|
61
|
+
while (taken.has(key)) key = `_${key}`
|
|
62
|
+
return key
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Keeps the top {@link DONUT_MAX_SLICES} by value (in original order) and sums the rest into one
|
|
66
|
+
* trailing "Other" entry. Non-finite and negative values count as 0. */
|
|
67
|
+
export function foldOther<T extends { key: string; value: number }>(
|
|
68
|
+
slices: readonly T[],
|
|
69
|
+
): { kept: T[]; other: number | null } {
|
|
70
|
+
const safe = slices.map((s) => ({ ...s, value: safeValue(s.value) }))
|
|
71
|
+
if (safe.length <= DONUT_MAX_SLICES) return { kept: safe, other: null }
|
|
72
|
+
const top = new Set(
|
|
73
|
+
safe
|
|
74
|
+
.toSorted((a, b) => b.value - a.value)
|
|
75
|
+
.slice(0, DONUT_MAX_SLICES)
|
|
76
|
+
.map((s) => s.key),
|
|
77
|
+
)
|
|
78
|
+
return {
|
|
79
|
+
kept: safe.filter((s) => top.has(s.key)),
|
|
80
|
+
other: safe.reduce((sum, s) => (top.has(s.key) ? sum : sum + s.value), 0),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -124,15 +124,20 @@ export type AutoMarginInput = {
|
|
|
124
124
|
/** Degrees of counter-clockwise rotation applied to the x tick labels. Default 0. */
|
|
125
125
|
rotate?: number
|
|
126
126
|
/** Tick-label font size. Default `VX.axisFont`. A phone-tier chart passes its own smaller size
|
|
127
|
-
* (`
|
|
127
|
+
* (`chartMetrics().axisFont`) — the measured label must be the painted label. */
|
|
128
128
|
fontPx?: number
|
|
129
129
|
/**
|
|
130
130
|
* Per-side FLOORS. Default `VX.margin`. The phone tier passes its tightened set
|
|
131
|
-
* (`
|
|
131
|
+
* (`chartMetrics().margin`) so a static 44px left gutter does not spend an eighth of a 360px
|
|
132
132
|
* chart on a three-character label. The law itself does not move: a side may still only ever
|
|
133
133
|
* grow past its floor.
|
|
134
134
|
*/
|
|
135
135
|
floor?: ChartMargin
|
|
136
|
+
/** X labels anchored inward at the terminals (`AxisBottomDate anchorTerminals`): the last label
|
|
137
|
+
* no longer centres on the right edge, so the half-label right reservation is dropped. */
|
|
138
|
+
anchorTerminals?: boolean
|
|
139
|
+
/** Lines the tallest (wrapped) x label takes. Default 1. */
|
|
140
|
+
bottomLines?: number
|
|
136
141
|
/** Explicit per-side overrides — applied LAST, so an escape hatch always wins. */
|
|
137
142
|
override?: Partial<ChartMargin>
|
|
138
143
|
}
|
|
@@ -157,7 +162,16 @@ export type AutoMarginInput = {
|
|
|
157
162
|
*/
|
|
158
163
|
export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
|
|
159
164
|
const fontPx = input.fontPx ?? VX.axisFont
|
|
160
|
-
const {
|
|
165
|
+
const {
|
|
166
|
+
left = [],
|
|
167
|
+
right = [],
|
|
168
|
+
bottom = [],
|
|
169
|
+
rotate = 0,
|
|
170
|
+
floor = VX.margin,
|
|
171
|
+
anchorTerminals = false,
|
|
172
|
+
bottomLines = 1,
|
|
173
|
+
override,
|
|
174
|
+
} = input
|
|
161
175
|
|
|
162
176
|
const leftWidth = maxTextWidth(left, fontPx)
|
|
163
177
|
const rightWidth = maxTextWidth(right, fontPx)
|
|
@@ -166,7 +180,8 @@ export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
|
|
|
166
180
|
// Measured EXACTLY as painted: through the same `dx`/`dy` `primitives/Axes.tsx` nudges a rotated
|
|
167
181
|
// label by, and from the baseline the axis anchors it at.
|
|
168
182
|
const rotated = rotatedLabelExtents({ labelWidth: bottomWidth, fontPx, rotate })
|
|
169
|
-
const bottomExtent =
|
|
183
|
+
const bottomExtent =
|
|
184
|
+
rotate === 0 ? lineHeight(fontPx) * Math.max(bottomLines, 1) + AXIS_TICK_GAP : rotated.below
|
|
170
185
|
|
|
171
186
|
return {
|
|
172
187
|
top: override?.top ?? floor.top,
|
|
@@ -175,7 +190,11 @@ export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
|
|
|
175
190
|
Math.ceil(
|
|
176
191
|
Math.max(
|
|
177
192
|
floor.right,
|
|
178
|
-
right.length > 0
|
|
193
|
+
right.length > 0
|
|
194
|
+
? rightWidth + AXIS_TICK_GAP
|
|
195
|
+
: rotate === 0 && !anchorTerminals
|
|
196
|
+
? bottomWidth / 2
|
|
197
|
+
: 0,
|
|
179
198
|
),
|
|
180
199
|
),
|
|
181
200
|
bottom: override?.bottom ?? Math.ceil(Math.max(floor.bottom, bottomExtent)),
|