basalt-ui 1.30.2 → 1.31.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 +31 -0
- package/MIGRATING.md +172 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +1 -1
- package/configs/oxlint-plugin.js +254 -4
- package/configs/oxlint-plugin.test.ts +145 -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 +54 -41
- 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 +145 -131
- 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 +83 -68
- 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 +271 -125
- 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 +74 -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 +133 -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 +212 -71
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +4 -3
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +37 -26
- 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 +29 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +13 -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 +76 -29
- 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/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 +8 -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 +1 -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 +16 -4
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- 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.d.ts +23 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +339 -141
- 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 +30 -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 +67 -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/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +22 -2
- 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/index.d.ts +12 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +57 -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 +36 -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/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 +17 -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 +11 -1
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +19 -4
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +15 -3
- 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 -68
- package/src/charts/primitives/ChartCard.tsx +141 -48
- package/src/charts/primitives/ChartFrame.tsx +243 -60
- package/src/charts/primitives/ChartLegend.tsx +257 -73
- package/src/charts/primitives/ChartTooltip.tsx +8 -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 +15 -4
- package/src/charts/primitives/chart-card-context.ts +37 -0
- package/src/charts/primitives/chart-frame-layout.ts +214 -177
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- 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 +8 -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 +1 -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 +16 -4
- 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.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +503 -164
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +30 -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 +183 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/provider/index.tsx +53 -1
- 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/index.tsx +200 -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 +36 -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/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
|
@@ -3,104 +3,106 @@
|
|
|
3
3
|
* is testable without a `ResizeObserver` and `ChartFrame.tsx` stays the measuring/rendering shell.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { HIT_COARSE } from '../../common/hit-floor'
|
|
6
7
|
import { VX } from '../../tokens'
|
|
8
|
+
import type { ContainerClass } from '../../tokens/size-classes'
|
|
7
9
|
import type { ChartMargin } from '../../tokens'
|
|
8
10
|
import type { LegendEntry } from './ChartLegend'
|
|
11
|
+
import type { SeriesRole } from '../series'
|
|
9
12
|
import { measureText } from '../utils/measure-text'
|
|
10
13
|
|
|
11
14
|
/** Line box of one legend row at `VX.legendFontSize`. */
|
|
12
15
|
const LEGEND_LINE_H = Math.ceil(VX.legendFontSize * 1.35)
|
|
13
16
|
/** `ChartLegend`'s own vertical wrapper padding (`8px 0 2px`). */
|
|
14
17
|
const LEGEND_PAD_Y = 10
|
|
18
|
+
/** Gap between two wrapped legend rows — tight (4-6px); the wider `VX.legendGap` separates entries. */
|
|
19
|
+
export const LEGEND_ROW_GAP = 6
|
|
15
20
|
/** Swatch plus the gap to its label — the fixed part of a legend entry's width. */
|
|
16
21
|
const LEGEND_SWATCH_W = 24
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
24
|
+
* The real gap a dots-mode entry needs so its `[data-basalt-hit]::after` overlay can reach the
|
|
25
|
+
* full 44px coarse floor with NO overlap into its neighbour's overlay (`hit-floor.layout.test.ts` +
|
|
26
|
+
* `hit-overlap.layout.test.ts`, wave 4's cap: `styles.css`'s `::after` grows to
|
|
27
|
+
* `min(max(size,44), size + 2 + gap)`). Two 44px-wide overlays centred on same-size hosts touch
|
|
28
|
+
* with zero overlap only once their PITCH (real gap + host size) is itself >= 44 — below that, the
|
|
29
|
+
* cap can only shrink the overlay below 44 (honouring WCAG 2.5.8's spacing route) or let it overlap;
|
|
30
|
+
* there is no gap value that lets it hit 44 exactly while staying flush against a same-size sibling.
|
|
31
|
+
* `LEGEND_DOT_SIZE` is 8px, so the floor is 44 − 8 − 2 = 34; a few px of headroom against rounding.
|
|
22
32
|
*/
|
|
23
|
-
export
|
|
33
|
+
export const LEGEND_DOT_SIZE = 8
|
|
34
|
+
export const DOTS_HIT_GAP = HIT_COARSE - LEGEND_DOT_SIZE - 2 + 4
|
|
35
|
+
/** One dot's full visual footprint in a wrapped row — the fit basis for dots mode. A chip's
|
|
36
|
+
* swatch+label width badly overstates what a dot costs, so measuring dots as chips wrapped a
|
|
37
|
+
* 4-entry legend to its own `All N` row. */
|
|
38
|
+
const DOT_PITCH = LEGEND_DOT_SIZE + DOTS_HIT_GAP
|
|
39
|
+
|
|
40
|
+
const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
|
|
41
|
+
const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
|
|
24
42
|
|
|
25
43
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* A viewport breakpoint answers the wrong question. A chart in a 2-column grid cell on a 1440px
|
|
30
|
-
* desktop is exactly as narrow as one filling a phone, and `@media` cannot see that; the
|
|
31
|
-
* `ResizeObserver` `ChartFrame` already runs can. It also keeps this file (and the whole chart
|
|
32
|
-
* layer) Mantine-free — `theme.breakpoints` is on the coupled side of the boundary.
|
|
33
|
-
*
|
|
34
|
-
* An UNMEASURED box (`containerW <= 0` — SSR, or before the observer's first callback) resolves
|
|
35
|
-
* to `'desktop'`: the first frame must not paint phone chrome that then re-lays-out one frame
|
|
36
|
-
* later, and `resolvePlotRect` already treats an unmeasured width as the first-frame case.
|
|
44
|
+
* Order entries series → overlay → reference and record where a divider belongs — the ONE ordering
|
|
45
|
+
* both `ChartLegend`'s render and the resolver's fit math (`chart-layout.ts`) use, so a dots-mode
|
|
46
|
+
* fit never disagrees with what actually wraps (a grouped legend renders dividers as flex items).
|
|
37
47
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
48
|
+
export function orderEntries(
|
|
49
|
+
items: readonly LegendEntry[],
|
|
50
|
+
groups: boolean,
|
|
51
|
+
): { entries: LegendEntry[]; dividerAfter: ReadonlySet<number> } {
|
|
52
|
+
if (!groups) return { entries: [...items], dividerAfter: new Set() }
|
|
53
|
+
|
|
54
|
+
const dividerAfter = new Set<number>()
|
|
55
|
+
const entries: LegendEntry[] = []
|
|
56
|
+
const nonEmptyGroups = GROUP_ORDER.map((role) =>
|
|
57
|
+
items.filter((item) => roleOf(item) === role),
|
|
58
|
+
).filter((group) => group.length > 0)
|
|
59
|
+
nonEmptyGroups.forEach((group, i) => {
|
|
60
|
+
entries.push(...group)
|
|
61
|
+
if (i < nonEmptyGroups.length - 1) dividerAfter.add(entries.length - 1)
|
|
62
|
+
})
|
|
63
|
+
return { entries, dividerAfter }
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
67
|
+
* The two metric sets a chart's chrome resolves to. Internally the container class
|
|
68
|
+
* (`micro`/`compact` → `'phone'`, `regular`/`wide` → `'desktop'`) picks one; there is no `tablet`.
|
|
69
|
+
* Internal only — not on any public barrel.
|
|
70
|
+
*/
|
|
71
|
+
export type ChartTier = 'phone' | 'desktop'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A chart height stated per container step instead of as one number — the declarative escape from
|
|
75
|
+
* "every chart on this page is 260px tall on a 375px phone too".
|
|
45
76
|
*
|
|
46
|
-
* `base` is
|
|
47
|
-
*
|
|
48
|
-
* vocabulary they already write, and nothing more than `sm`/`md`/`lg` is offered: `xs`/`xl` would
|
|
49
|
-
* be two more rungs nobody has asked a chart to have.
|
|
77
|
+
* `base` is the height below the `regular` class; `regular` and `wide` take effect from
|
|
78
|
+
* `CONTAINER_CLASSES.regular` / `.wide` of the frame's MEASURED width (never the viewport).
|
|
50
79
|
*
|
|
51
80
|
* ```tsx
|
|
52
|
-
* <ChartFrame series={series} height={{ base: 180,
|
|
81
|
+
* <ChartFrame series={series} height={{ base: 180, wide: 260 }}>{…}</ChartFrame>
|
|
53
82
|
* ```
|
|
54
83
|
*/
|
|
55
84
|
export type ResponsiveChartHeight = {
|
|
56
|
-
/** Below the narrowest STATED step — and NOT the first-frame value; see {@link resolveFrameHeight}. */
|
|
57
85
|
base: number
|
|
58
|
-
/** From
|
|
59
|
-
|
|
60
|
-
/** From
|
|
61
|
-
|
|
62
|
-
/** From 1200px up. */
|
|
63
|
-
lg?: number
|
|
86
|
+
/** From the `regular` container class (480px) up. */
|
|
87
|
+
regular?: number
|
|
88
|
+
/** From the `wide` container class (800px) up. */
|
|
89
|
+
wide?: number
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* MEASURED box, never the viewport, for the reason {@link resolveChartTier} states: a chart in a
|
|
72
|
-
* `PageAside`-squeezed grid cell on a 1440px desktop is as narrow as one on a phone, and the
|
|
73
|
-
* viewport agrees with neither. Keeping them as plain numbers here is also what keeps this file
|
|
74
|
-
* Mantine-free — `theme.breakpoints` is on the coupled side of the boundary.
|
|
75
|
-
*/
|
|
76
|
-
const HEIGHT_STEP_WIDTH = { sm: 768, md: 992, lg: 1200 } as const
|
|
77
|
-
|
|
78
|
-
/** Widest-first, so the first step that fits is the answer. */
|
|
79
|
-
const HEIGHT_STEPS = ['lg', 'md', 'sm'] as const
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Resolve a `height` prop — a plain number, or a {@link ResponsiveChartHeight} — against the
|
|
83
|
-
* measured container width.
|
|
84
|
-
*
|
|
85
|
-
* A plain number passes through untouched: this is a WIDENING of the prop, not a replacement, and
|
|
86
|
-
* every existing `height={240}` must keep meaning exactly 240.
|
|
87
|
-
*
|
|
88
|
-
* An UNMEASURED box (`containerW <= 0` — SSR, or before the `ResizeObserver`'s first callback)
|
|
89
|
-
* resolves to the LARGEST stated step, matching {@link resolveChartTier}'s first-frame rule: the
|
|
90
|
-
* first paint must not be phone-shaped chrome that grows one frame later. Falling to `base` there
|
|
91
|
-
* would make every SSR'd chart short and then jump.
|
|
93
|
+
* Resolve a `height` prop — a plain number, or a {@link ResponsiveChartHeight} — for a container
|
|
94
|
+
* class. A plain number passes through untouched. The class is the measured one, or, before the
|
|
95
|
+
* first measurement, the one implied by the viewport ({@link resolveChartLayout}), so the first
|
|
96
|
+
* frame is not a different height from the second.
|
|
92
97
|
*/
|
|
93
98
|
export function resolveFrameHeight(
|
|
94
99
|
height: number | ResponsiveChartHeight,
|
|
95
|
-
|
|
100
|
+
containerClass: ContainerClass,
|
|
96
101
|
): number {
|
|
97
102
|
if (typeof height === 'number') return height
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
(name) => containerW >= HEIGHT_STEP_WIDTH[name] && height[name] !== undefined,
|
|
102
|
-
)
|
|
103
|
-
return step === undefined ? height.base : (height[step] ?? height.base)
|
|
103
|
+
if (containerClass === 'wide') return height.wide ?? height.regular ?? height.base
|
|
104
|
+
if (containerClass === 'regular') return height.regular ?? height.base
|
|
105
|
+
return height.base
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -114,16 +116,14 @@ const PHONE_MARGIN_SCALE = 0.75
|
|
|
114
116
|
* the smaller stroke widths a narrow chart draws. */
|
|
115
117
|
const PHONE_DOT_R = Math.max(VX.dotR - 1, 2)
|
|
116
118
|
|
|
117
|
-
/** Legend entries a phone-tier legend renders before rolling the rest into `+N more`. Two is the
|
|
118
|
-
* cap because a third entry wraps at every realistic phone width, and a wrapped legend is what
|
|
119
|
-
* eats the plot (`legendEntryCap`). */
|
|
120
|
-
const PHONE_LEGEND_MAX_ROWS = 2
|
|
121
|
-
|
|
122
119
|
/** Tooltip `minWidth`, per tier — 140px is 39% of a 360px screen. */
|
|
123
120
|
const TOOLTIP_MIN_WIDTH = { desktop: 140, phone: 110 } as const
|
|
124
121
|
|
|
125
|
-
/**
|
|
126
|
-
*
|
|
122
|
+
/**
|
|
123
|
+
* Every size a chart's chrome resolves per tier. One object so a new tier-sensitive size is added
|
|
124
|
+
* in one place and read by name, rather than each primitive branching on the tier itself. Internal
|
|
125
|
+
* only — not on any public barrel.
|
|
126
|
+
*/
|
|
127
127
|
export type ChartTierMetrics = {
|
|
128
128
|
/** Which tier these are, so a consumer of the metrics never needs both values threaded. */
|
|
129
129
|
tier: ChartTier
|
|
@@ -136,9 +136,6 @@ export type ChartTierMetrics = {
|
|
|
136
136
|
dotR: number
|
|
137
137
|
/** Floating tooltip `minWidth`, px. */
|
|
138
138
|
tooltipMinWidth: number
|
|
139
|
-
/** Default entry cap on the legend, or `undefined` for no default cap. An explicit
|
|
140
|
-
* `legend.maxRows` replaces it outright ({@link resolveLegendMaxRows}). */
|
|
141
|
-
legendMaxRows: number | undefined
|
|
142
139
|
/** Per-side margin FLOORS (`VX.margin` is a floor, never a ceiling — §1). */
|
|
143
140
|
margin: ChartMargin
|
|
144
141
|
}
|
|
@@ -149,7 +146,6 @@ const DESKTOP_METRICS: ChartTierMetrics = {
|
|
|
149
146
|
legendFontSize: VX.legendFontSize,
|
|
150
147
|
dotR: VX.dotR,
|
|
151
148
|
tooltipMinWidth: TOOLTIP_MIN_WIDTH.desktop,
|
|
152
|
-
legendMaxRows: undefined,
|
|
153
149
|
margin: VX.margin,
|
|
154
150
|
}
|
|
155
151
|
|
|
@@ -161,7 +157,6 @@ const PHONE_METRICS: ChartTierMetrics = {
|
|
|
161
157
|
legendFontSize: VX.text.xs,
|
|
162
158
|
dotR: PHONE_DOT_R,
|
|
163
159
|
tooltipMinWidth: TOOLTIP_MIN_WIDTH.phone,
|
|
164
|
-
legendMaxRows: PHONE_LEGEND_MAX_ROWS,
|
|
165
160
|
margin: {
|
|
166
161
|
top: Math.round(VX.margin.top * PHONE_MARGIN_SCALE),
|
|
167
162
|
right: Math.round(VX.margin.right * PHONE_MARGIN_SCALE),
|
|
@@ -170,58 +165,30 @@ const PHONE_METRICS: ChartTierMetrics = {
|
|
|
170
165
|
},
|
|
171
166
|
}
|
|
172
167
|
|
|
173
|
-
/** The
|
|
174
|
-
export function
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Which legend cap applies: the caller's, or the tier's own default.
|
|
180
|
-
*
|
|
181
|
-
* **An explicit `callerMaxRows` wins OUTRIGHT.** This used to be `Math.min(caller, tier)`, which
|
|
182
|
-
* made the documented per-chart opt-out ("opt back out with an explicit `legend.maxRows`") false in
|
|
183
|
-
* the one direction anybody needs it. The tier's `PHONE_LEGEND_MAX_ROWS` is a DEFAULT for charts
|
|
184
|
-
* that said nothing — a chart that names a number has already answered the question the default
|
|
185
|
-
* exists to answer, and it matters because the tier keys on the measured box: a 380px inspector
|
|
186
|
-
* panel on a 1440px desktop is "a phone", so a six-series legend rolled up to two rows + `+4 more`
|
|
187
|
-
* with no way to say no. `margin` and `xLabelRotate` always had that escape; the legend did not.
|
|
188
|
-
*
|
|
189
|
-
* The measured {@link legendEntryCap} a `fill` frame applies is NOT a second ceiling on top of
|
|
190
|
-
* this: it resolves an ABSENT cap only. 1.30.0 ran it over the caller's number as well
|
|
191
|
-
* (`Math.min(fitted, caller)`), which made "wins outright" false again in the one place it was
|
|
192
|
-
* needed — meteo measured a 7-entry legend pinned to 2 entries at `maxRows` 3, 6 AND 99, with 5
|
|
193
|
-
* series drawn in colours the legend refused to name. The fit is not physics either: it derives
|
|
194
|
-
* from `VX.minPlotHeight`, which is a framework DEFAULT about the plot. Two defaults do not
|
|
195
|
-
* outvote the one number a caller stated, so the PLOT yields the height instead
|
|
196
|
-
* ({@link resolvePlotRect}'s `legendWins`) — visibly, in the frame the caller sized.
|
|
197
|
-
*/
|
|
198
|
-
export function resolveLegendMaxRows(input: {
|
|
199
|
-
callerMaxRows?: number | undefined
|
|
200
|
-
tier: ChartTier
|
|
201
|
-
}): number | undefined {
|
|
202
|
-
return input.callerMaxRows ?? chartTierMetrics(input.tier).legendMaxRows
|
|
168
|
+
/** The metric set a container class resolves to (`micro`/`compact` → phone, else desktop). */
|
|
169
|
+
export function chartMetrics(containerClass: ContainerClass): ChartTierMetrics {
|
|
170
|
+
return containerClass === 'micro' || containerClass === 'compact'
|
|
171
|
+
? PHONE_METRICS
|
|
172
|
+
: DESKTOP_METRICS
|
|
203
173
|
}
|
|
204
174
|
|
|
205
175
|
/**
|
|
206
176
|
* The whole legend-cap decision in one place: which cap `ChartLegend` gets, and whether the plot
|
|
207
|
-
* pays for it.
|
|
208
|
-
*
|
|
209
|
-
* It lives here rather than inline in `ChartFrame` because the COMPOSITION is what broke. 1.30.0
|
|
210
|
-
* had both halves right on their own — {@link resolveLegendMaxRows} honoured a stated cap,
|
|
211
|
-
* {@link legendEntryCap} measured a fit — and then ran the second over the first, which no test
|
|
212
|
-
* could see because neither pure function was wrong and the frame that combined them needs a
|
|
213
|
-
* `ResizeObserver` to test. Now the combination is a pure function too.
|
|
177
|
+
* pays for it. Pure, because the COMPOSITION is what once broke (1.30.0 ran a measured fit over a
|
|
178
|
+
* caller's stated number) and the frame that combines them needs a `ResizeObserver` to test.
|
|
214
179
|
*
|
|
215
180
|
* Three cases, in order:
|
|
216
|
-
* 1. The caller STATED
|
|
217
|
-
* (`legendWins`). Nothing measured
|
|
218
|
-
* 2. No `fill` band → the
|
|
219
|
-
* 3. A `fill` band with nothing stated → the
|
|
181
|
+
* 1. The caller STATED `legend.maxRows` (deprecated, still honoured) → it is the cap, and on a
|
|
182
|
+
* `fill` band the plot yields (`legendWins`). Nothing measured trims it.
|
|
183
|
+
* 2. No `fill` band → the resolver's width-measured fit (`fittedMaxRows`), because the frame grows.
|
|
184
|
+
* 3. A `fill` band with nothing stated → the tighter of that fit and the height-measured
|
|
185
|
+
* {@link legendEntryCap}, because a `fill` box cannot grow.
|
|
220
186
|
*/
|
|
221
187
|
export function resolveLegendRollup(input: {
|
|
222
188
|
/** The caller's own `legend.maxRows`. */
|
|
223
189
|
statedMaxRows?: number | undefined
|
|
224
|
-
|
|
190
|
+
/** `resolveChartLayout().legend.visible` when it overflows, else `undefined`. */
|
|
191
|
+
fittedMaxRows?: number | undefined
|
|
225
192
|
/** A visible top/bottom legend on a `fill` frame — the one shape whose box cannot grow. */
|
|
226
193
|
fillBand: boolean
|
|
227
194
|
items: readonly LegendEntry[]
|
|
@@ -229,16 +196,15 @@ export function resolveLegendRollup(input: {
|
|
|
229
196
|
/** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
|
|
230
197
|
available: number
|
|
231
198
|
}): { maxRows: number | undefined; legendWins: boolean } {
|
|
232
|
-
const { statedMaxRows,
|
|
199
|
+
const { statedMaxRows, fittedMaxRows, fillBand, items, containerW, available } = input
|
|
233
200
|
if (statedMaxRows !== undefined) return { maxRows: statedMaxRows, legendWins: fillBand }
|
|
234
|
-
|
|
235
|
-
if (!fillBand) return { maxRows: tierMaxRows, legendWins: false }
|
|
201
|
+
if (!fillBand) return { maxRows: fittedMaxRows, legendWins: false }
|
|
236
202
|
return {
|
|
237
203
|
maxRows: legendEntryCap({
|
|
238
204
|
items,
|
|
239
205
|
containerW,
|
|
240
206
|
available,
|
|
241
|
-
...(
|
|
207
|
+
...(fittedMaxRows !== undefined && { defaultMaxRows: fittedMaxRows }),
|
|
242
208
|
}),
|
|
243
209
|
legendWins: false,
|
|
244
210
|
}
|
|
@@ -248,22 +214,13 @@ export function resolveLegendRollup(input: {
|
|
|
248
214
|
* Resolve the plot rect from the measured box and the measured legend band. Pure and exported so
|
|
249
215
|
* the two floors below are testable without a `ResizeObserver`.
|
|
250
216
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* `height={240}` toward zero and the body stopped rendering entirely. The plot stops at
|
|
259
|
-
* `VX.minPlotHeight` and the frame's own box grows by the difference instead — it is a flex
|
|
260
|
-
* column with `height: auto`, so that growth is automatic. Under `fill` the box CANNOT grow, so
|
|
261
|
-
* the legend is capped instead ({@link legendEntryCap}) — UNLESS the caller stated
|
|
262
|
-
* `legend.maxRows`, which is `legendWins`: there the floor itself yields, because a floor is a
|
|
263
|
-
* default and the caller's number is not, but only as far as the box HAS room
|
|
264
|
-
* ({@link SELF_MEASURED_SLACK}). An unmeasured box (`resolvedHeight <= 0`, i.e. a `fill` frame
|
|
265
|
-
* before its first measurement) stays at 0 and renders nothing, exactly as before — the floor
|
|
266
|
-
* must never invent a height for a box nobody has measured.
|
|
217
|
+
* `minWidth` is a FIRST-FRAME guard, applied only while `containerW` is still 0 (unmeasured, or
|
|
218
|
+
* SSR); once measured the plot tracks the box exactly, floored at 1 so no scale divides by zero.
|
|
219
|
+
* The legend band is subtracted first, and the remainder stops at `VX.minPlotHeight` — the frame's
|
|
220
|
+
* flex box grows by the difference under a fixed height, and under `fill` (which cannot grow) the
|
|
221
|
+
* legend is capped instead ({@link legendEntryCap}). A caller-stated `legend.maxRows` yields the
|
|
222
|
+
* floor itself (`legendWins`) as far as the box has room ({@link SELF_MEASURED_SLACK}); an
|
|
223
|
+
* unmeasured box (`resolvedHeight <= 0`) stays 0 and renders nothing.
|
|
267
224
|
*/
|
|
268
225
|
export function resolvePlotRect(input: {
|
|
269
226
|
containerW: number
|
|
@@ -297,33 +254,20 @@ export function resolvePlotRect(input: {
|
|
|
297
254
|
}
|
|
298
255
|
}
|
|
299
256
|
|
|
300
|
-
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* MEASURED exact in the collapse this guards (both `ResizeObserver` contentRects read 29.375 in
|
|
304
|
-
* headless Chrome, `tests/layout/charts.layout.test.ts` INVARIANT 7) — the slack exists only so a
|
|
305
|
-
* future rounding or border difference between the two observers cannot re-open a fixpoint that
|
|
306
|
-
* has no other way out. A plot this thin has nothing to draw either way.
|
|
307
|
-
*/
|
|
257
|
+
/** Sub-pixel slack on "the frame measures its own legend band and nothing else" — absorbs a
|
|
258
|
+
* future rounding or border difference between the two observers without re-opening the zero
|
|
259
|
+
* fixpoint below. */
|
|
308
260
|
const SELF_MEASURED_SLACK = 0.5
|
|
309
261
|
|
|
310
262
|
/**
|
|
311
263
|
* Is the frame's height its OWN content rather than a box something else stated?
|
|
312
264
|
*
|
|
313
|
-
* `
|
|
314
|
-
* the
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* FIXPOINT AT ZERO — plot 0 → content is the legend → box = band → room 0 → plot 0, forever, and
|
|
320
|
-
* the chart never appears. Keeping the floor while `room` is 0 converges on the same box a `fill`
|
|
321
|
-
* frame in an unsized parent has always had (plot `VX.minPlotHeight`, box = plot + band), from
|
|
322
|
-
* which `room` is positive and `legendWins` applies normally.
|
|
323
|
-
*
|
|
324
|
-
* A NEGATIVE `room` is not this case and must keep yielding: a self-measured box can never be
|
|
325
|
-
* shorter than its own content, so a band taller than the box means the box was stated by
|
|
326
|
-
* something else, and spending all of it on legend rows is the caller's own arithmetic.
|
|
265
|
+
* Under `fill` a frame in an unsized parent resolves its height to `auto`; its only children are
|
|
266
|
+
* the plot and the legend, so `room === 0` means the box IS the legend band. There `legendWins`
|
|
267
|
+
* must NOT yield the floor — with the floor gone the sequence has a fixpoint at zero (plot 0 →
|
|
268
|
+
* content is the legend → box = band → room 0 → plot 0 forever). A NEGATIVE `room` is not this
|
|
269
|
+
* case: a self-measured box can never be shorter than its own content, so the box was stated by
|
|
270
|
+
* something else, and spending all of it on legend rows is the caller's arithmetic.
|
|
327
271
|
*/
|
|
328
272
|
function isSelfMeasured(room: number): boolean {
|
|
329
273
|
return room >= 0 && room < SELF_MEASURED_SLACK
|
|
@@ -331,7 +275,7 @@ function isSelfMeasured(room: number): boolean {
|
|
|
331
275
|
|
|
332
276
|
/** Height of a legend band `rows` rows tall, wrapper padding included. */
|
|
333
277
|
const legendBandHeight = (rows: number): number =>
|
|
334
|
-
LEGEND_PAD_Y + rows * LEGEND_LINE_H + Math.max(rows - 1, 0) *
|
|
278
|
+
LEGEND_PAD_Y + rows * LEGEND_LINE_H + Math.max(rows - 1, 0) * LEGEND_ROW_GAP
|
|
335
279
|
|
|
336
280
|
/** Width one legend entry occupies — swatch, gap, label, and the note that rides after it. */
|
|
337
281
|
const legendEntryWidth = (item: LegendEntry): number =>
|
|
@@ -341,15 +285,27 @@ const legendEntryWidth = (item: LegendEntry): number =>
|
|
|
341
285
|
VX.legendFontSize,
|
|
342
286
|
)
|
|
343
287
|
|
|
344
|
-
/**
|
|
345
|
-
*
|
|
346
|
-
|
|
288
|
+
/**
|
|
289
|
+
* The greedy chip-width wrap {@link entriesWithinRows} runs — split out so
|
|
290
|
+
* {@link entriesWithinChipRows} can run it a second time against a narrower, reserve-adjusted
|
|
291
|
+
* width without duplicating the wrap arithmetic. `allowZero` (default false) drops the "the first
|
|
292
|
+
* entry always fits" guarantee a bare row-fit needs (a raw width-vs-0 check is meaningless
|
|
293
|
+
* otherwise): without it, the SAME entry the caller is about to also draw an `All N` chip beside
|
|
294
|
+
* would always be counted as fitting even when the two together provably don't.
|
|
295
|
+
*/
|
|
296
|
+
function fitChipRows(
|
|
297
|
+
items: readonly LegendEntry[],
|
|
298
|
+
width: number,
|
|
299
|
+
rows: number,
|
|
300
|
+
allowZero = false,
|
|
301
|
+
): number {
|
|
347
302
|
let row = 1
|
|
348
303
|
let x = 0
|
|
349
304
|
let fitted = 0
|
|
350
305
|
for (const item of items) {
|
|
351
306
|
const w = legendEntryWidth(item)
|
|
352
307
|
const next = x === 0 ? w : x + VX.legendGap + w
|
|
308
|
+
if (x === 0 && allowZero && next > width) return 0
|
|
353
309
|
if (next > width && x > 0) {
|
|
354
310
|
row += 1
|
|
355
311
|
if (row > rows) return fitted
|
|
@@ -362,20 +318,101 @@ function entriesWithinRows(items: readonly LegendEntry[], width: number, rows: n
|
|
|
362
318
|
return fitted
|
|
363
319
|
}
|
|
364
320
|
|
|
321
|
+
/** How many of `items` fit in `rows` wrapped rows of `width` — the same greedy wrap the flex
|
|
322
|
+
* container performs, measured rather than assumed (`docs/CHARTS-SPEC.md` §1). */
|
|
323
|
+
export function entriesWithinRows(
|
|
324
|
+
items: readonly LegendEntry[],
|
|
325
|
+
width: number,
|
|
326
|
+
rows: number,
|
|
327
|
+
): number {
|
|
328
|
+
return fitChipRows(items, width, rows)
|
|
329
|
+
}
|
|
330
|
+
|
|
365
331
|
/**
|
|
366
|
-
*
|
|
332
|
+
* Chips-mode counterpart of {@link entriesWithinDotRows}: once the un-reserved fit overflows, a
|
|
333
|
+
* second pass measures against the width LESS the `All N` chip's own footprint, so the count this
|
|
334
|
+
* returns still leaves the chip room to sit beside it in the same row — same reserve law the dots
|
|
335
|
+
* fit already applied, now applied to chip width instead of a dot's pitch. Returns 0 when not even
|
|
336
|
+
* the first chip fits once the chip's width is reserved, so the row renders the disclosure alone
|
|
337
|
+
* rather than one crowded chip plus a chip that doesn't actually fit next to it.
|
|
338
|
+
*/
|
|
339
|
+
export function entriesWithinChipRows(
|
|
340
|
+
items: readonly LegendEntry[],
|
|
341
|
+
width: number,
|
|
342
|
+
rows: number,
|
|
343
|
+
): number {
|
|
344
|
+
const total = items.length
|
|
345
|
+
const fittedNoReserve = fitChipRows(items, width, rows)
|
|
346
|
+
if (fittedNoReserve >= total) return fittedNoReserve
|
|
347
|
+
const reserve = measureText(`All ${total}`, VX.legendFontSize) + VX.legendGap
|
|
348
|
+
return fitChipRows(items, Math.max(width - reserve, 0), rows, true)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The greedy dot-pitch wrap `entriesWithinDotRows` runs twice — once optimistically, once against
|
|
352
|
+
* a width that already gave up the `All N` chip's room. A group divider is its own flex item, so it
|
|
353
|
+
* costs one more pitch before the next dot. `allowZero` (default false) mirrors {@link fitChipRows}'s
|
|
354
|
+
* own flag: without it the first dot always counts as fitted regardless of `width`, which is wrong
|
|
355
|
+
* for the reserved pass once reserving has left no usable room at all. */
|
|
356
|
+
function fitDotRows(
|
|
357
|
+
items: readonly LegendEntry[],
|
|
358
|
+
width: number,
|
|
359
|
+
rows: number,
|
|
360
|
+
dividerAfter: ReadonlySet<number>,
|
|
361
|
+
allowZero = false,
|
|
362
|
+
): number {
|
|
363
|
+
let row = 1
|
|
364
|
+
let x = 0
|
|
365
|
+
let fitted = 0
|
|
366
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
367
|
+
// `DOT_PITCH` already IS one dot's full footprint (its own size plus the gap to a neighbour,
|
|
368
|
+
// `LEGEND_DOT_SIZE + DOTS_HIT_GAP`) — adding `LEGEND_DOT_SIZE` again here double-counted every
|
|
369
|
+
// dot after the row's first, wrapping a legend to `All N` well before it actually overflowed.
|
|
370
|
+
const next = x === 0 ? LEGEND_DOT_SIZE : x + DOT_PITCH
|
|
371
|
+
if (x === 0 && allowZero && next > width) return 0
|
|
372
|
+
if (next > width && x > 0) {
|
|
373
|
+
row += 1
|
|
374
|
+
if (row > rows) return fitted
|
|
375
|
+
x = LEGEND_DOT_SIZE
|
|
376
|
+
} else {
|
|
377
|
+
x = next
|
|
378
|
+
}
|
|
379
|
+
fitted += 1
|
|
380
|
+
if (dividerAfter.has(i)) x += DOT_PITCH
|
|
381
|
+
}
|
|
382
|
+
return fitted
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* How many of `items` fit as DOTS in `rows` wrapped rows of `width` — the dots-mode counterpart of
|
|
387
|
+
* {@link entriesWithinRows}, measured at the dot's own pitch rather than a chip's width.
|
|
388
|
+
* `dividerAfter` (from {@link orderEntries}) counts a rendered group divider's own gap.
|
|
367
389
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
|
|
373
|
-
|
|
390
|
+
* Two passes: every entry visible with no `All N` chip (if that fits, no chip is drawn); otherwise
|
|
391
|
+
* reserve the chip's measured width up front so it can never wrap to a row of its own — and, once
|
|
392
|
+
* reserved, allow the answer to be 0: a chip wide enough to leave no room for even the first dot
|
|
393
|
+
* means the row draws the disclosure alone, not one dot crowding a chip that no longer fits beside it.
|
|
394
|
+
*/
|
|
395
|
+
export function entriesWithinDotRows(
|
|
396
|
+
items: readonly LegendEntry[],
|
|
397
|
+
width: number,
|
|
398
|
+
rows: number,
|
|
399
|
+
dividerAfter: ReadonlySet<number> = new Set(),
|
|
400
|
+
): number {
|
|
401
|
+
const total = items.length
|
|
402
|
+
const fittedNoReserve = fitDotRows(items, width, rows, dividerAfter)
|
|
403
|
+
if (fittedNoReserve >= total) return fittedNoReserve
|
|
404
|
+
const reserve = measureText(`All ${total}`, VX.legendFontSize) + DOT_PITCH
|
|
405
|
+
return fitDotRows(items, Math.max(width - reserve, 0), rows, dividerAfter, true)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The entry cap a `fill` frame's legend must respect so the plot keeps `VX.minPlotHeight`. A
|
|
410
|
+
* `fill` frame is pinned to its cell, so the only lever is `ChartLegend`'s `maxRows` rollup; how
|
|
411
|
+
* many entries fit follows from MEASURING the labels, not assuming one per row. Returns
|
|
412
|
+
* `undefined` when the whole legend already fits.
|
|
374
413
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* documented escape inert in exactly the narrow `fill` panel it exists for
|
|
378
|
-
* ({@link resolveLegendMaxRows}).
|
|
414
|
+
* Resolves an ABSENT cap only: a caller who stated `legend.maxRows` never reaches here
|
|
415
|
+
* ({@link resolveLegendRollup} routes around it), so the documented escape is never made inert.
|
|
379
416
|
*/
|
|
380
417
|
export function legendEntryCap(input: {
|
|
381
418
|
items: readonly LegendEntry[]
|
|
@@ -383,8 +420,8 @@ export function legendEntryCap(input: {
|
|
|
383
420
|
/** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
|
|
384
421
|
available: number
|
|
385
422
|
/**
|
|
386
|
-
* The cap that applies when nothing explicit was stated —
|
|
387
|
-
*
|
|
423
|
+
* The cap that applies when nothing explicit was stated — the resolver's width fit. Two
|
|
424
|
+
* defaults contending: the smaller wins.
|
|
388
425
|
*/
|
|
389
426
|
defaultMaxRows?: number
|
|
390
427
|
}): number | undefined {
|
|
@@ -392,7 +429,7 @@ export function legendEntryCap(input: {
|
|
|
392
429
|
if (containerW <= 0 || items.length === 0) return defaultMaxRows
|
|
393
430
|
const rows = Math.max(
|
|
394
431
|
1,
|
|
395
|
-
Math.floor((available - LEGEND_PAD_Y +
|
|
432
|
+
Math.floor((available - LEGEND_PAD_Y + LEGEND_ROW_GAP) / (LEGEND_LINE_H + LEGEND_ROW_GAP)),
|
|
396
433
|
)
|
|
397
434
|
if (
|
|
398
435
|
legendBandHeight(rows) <= available &&
|