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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveChartLayout` — the single, pure decision point for a chart's space
|
|
3
|
+
* (`docs/CHARTS-SPEC.md` §8): container class, height and legend fit. Also hosts the axis-economy
|
|
4
|
+
* ladder (`resolveYPlacement`, `planXLabels`, `yTickCount`, `compactNumber`, …) that
|
|
5
|
+
* `CartesianChart`/`useBandPlot` call directly. DOM-free and Mantine-free; deliberately not on any
|
|
6
|
+
* public barrel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveContainerClass } from '../../tokens/size-classes'
|
|
10
|
+
import type { ContainerClass, SIZE_CLASSES } from '../../tokens/size-classes'
|
|
11
|
+
import { VX } from '../../tokens'
|
|
12
|
+
import type { ChartMargin } from '../../tokens'
|
|
13
|
+
import { measureText } from '../utils/measure-text'
|
|
14
|
+
import type { LegendEntry } from './ChartLegend'
|
|
15
|
+
import {
|
|
16
|
+
entriesWithinChipRows,
|
|
17
|
+
entriesWithinDotRows,
|
|
18
|
+
entriesWithinRows,
|
|
19
|
+
orderEntries,
|
|
20
|
+
} from './chart-frame-layout'
|
|
21
|
+
|
|
22
|
+
export type ChartLayout = {
|
|
23
|
+
containerClass: ContainerClass
|
|
24
|
+
height: number
|
|
25
|
+
legend:
|
|
26
|
+
| { mode: 'none' }
|
|
27
|
+
| { mode: 'dots' | 'chips'; where: 'header' | 'band'; visible: number; overflow: number }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ChartLayoutInput = {
|
|
31
|
+
/** The chart frame's MEASURED width; `<= 0` is unmeasured (SSR, first paint). */
|
|
32
|
+
frameW: number
|
|
33
|
+
/** Width of the card's header legend slot; `0` when there is none (the legend takes a band). */
|
|
34
|
+
slotW: number
|
|
35
|
+
/** Viewport height in px (`0` unknown). Only read on a coarse pointer. */
|
|
36
|
+
viewportH: number
|
|
37
|
+
legendItems: readonly LegendEntry[]
|
|
38
|
+
/** Viewport class — decides the container class until the frame is measured. */
|
|
39
|
+
sizeClass: keyof typeof SIZE_CLASSES
|
|
40
|
+
coarse: boolean
|
|
41
|
+
/**
|
|
42
|
+
* The card's own `short` flag (`ChartCard`'s `measuredHeight < CARD_SHORT_HEIGHT`) — the ONE
|
|
43
|
+
* source of truth for "this legend gets at most one row", not a second narrower threshold.
|
|
44
|
+
* Default `false` (no card, or an unmeasured/tall one).
|
|
45
|
+
*/
|
|
46
|
+
cardShort?: boolean
|
|
47
|
+
/** The legend's own `groups` config — whether role dividers render (and so count toward the
|
|
48
|
+
* dots-mode fit). Default `false`. */
|
|
49
|
+
groups?: boolean
|
|
50
|
+
/** Consumer-stated values; each wins over the derived one. */
|
|
51
|
+
override?: { height?: number }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Height = width × ratio, per container class. */
|
|
55
|
+
const HEIGHT_RATIO = { micro: 0.5, compact: 0.62, regular: 0.5, wide: 0.4 } as const
|
|
56
|
+
const HEIGHT_MIN = 160
|
|
57
|
+
const HEIGHT_MAX = 420
|
|
58
|
+
/** A coarse pointer never gets a chart taller than this share of the viewport. */
|
|
59
|
+
const COARSE_VIEWPORT_SHARE = 0.45
|
|
60
|
+
/** Width assumed per class before the frame is measured. */
|
|
61
|
+
const UNMEASURED_WIDTH = { micro: 200, compact: 360, regular: 640, wide: 960 } as const
|
|
62
|
+
/** A y-axis tick every ~44px of plot height. */
|
|
63
|
+
const TICK_SPACING = 44
|
|
64
|
+
const COMPACT_LABEL_CHARS = 4
|
|
65
|
+
/** Gap kept between two adjacent x labels. */
|
|
66
|
+
const X_LABEL_GAP = 8
|
|
67
|
+
/** A legend shares one header row; in the band it may wrap to two. */
|
|
68
|
+
const LEGEND_ROWS = { header: 1, band: 2 } as const
|
|
69
|
+
|
|
70
|
+
/** Before the first measurement a compact viewport implies a compact container. */
|
|
71
|
+
const CONTAINER_CLASS_OF_SIZE_CLASS = {
|
|
72
|
+
compact: 'compact',
|
|
73
|
+
medium: 'regular',
|
|
74
|
+
expanded: 'wide',
|
|
75
|
+
} as const satisfies Record<keyof typeof SIZE_CLASSES, ContainerClass>
|
|
76
|
+
|
|
77
|
+
function containerClassFromSizeClass(sizeClass: keyof typeof SIZE_CLASSES): ContainerClass {
|
|
78
|
+
return CONTAINER_CLASS_OF_SIZE_CLASS[sizeClass]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
82
|
+
Math.min(Math.max(value, min), max)
|
|
83
|
+
|
|
84
|
+
function resolveHeight(input: ChartLayoutInput, containerClass: ContainerClass): number {
|
|
85
|
+
const override = input.override?.height
|
|
86
|
+
const width = input.frameW > 0 ? input.frameW : UNMEASURED_WIDTH[containerClass]
|
|
87
|
+
const derived =
|
|
88
|
+
override ?? clamp(Math.round(width * HEIGHT_RATIO[containerClass]), HEIGHT_MIN, HEIGHT_MAX)
|
|
89
|
+
if (!input.coarse || input.viewportH <= 0) return derived
|
|
90
|
+
// A viewport safety cap: it applies to a consumer's literal `height` too, so a fixed-height chart
|
|
91
|
+
// cannot overflow a short landscape-phone viewport.
|
|
92
|
+
return Math.min(derived, Math.round(input.viewportH * COARSE_VIEWPORT_SHARE))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A legend never rolls up fewer than 2 entries: "+1 more" hides nothing a chip would not show.
|
|
97
|
+
* `fitted` may legitimately be 0 — not even the first chip/dot fits once the `All N` chip's own
|
|
98
|
+
* width is reserved beside it — in which case the row is the disclosure alone (`visible: 0`),
|
|
99
|
+
* unless that would roll up the single-entry case the "fewer than 2" rule already protects.
|
|
100
|
+
*/
|
|
101
|
+
function rollUp(total: number, fitted: number): { visible: number; overflow: number } {
|
|
102
|
+
const visible = total - fitted < 2 ? total : fitted
|
|
103
|
+
return { visible, overflow: total - visible }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveLegend(
|
|
107
|
+
input: ChartLayoutInput,
|
|
108
|
+
containerClass: ContainerClass,
|
|
109
|
+
): ChartLayout['legend'] {
|
|
110
|
+
const total = input.legendItems.length
|
|
111
|
+
if (containerClass === 'micro' || total === 0) return { mode: 'none' }
|
|
112
|
+
const where = input.slotW > 0 ? 'header' : 'band'
|
|
113
|
+
const width = where === 'header' ? input.slotW : input.frameW
|
|
114
|
+
// Unmeasured (first paint, before P2-8's synchronous slot seed lands): assume everything fits
|
|
115
|
+
// rather than guessing a fold nothing has measured yet.
|
|
116
|
+
if (width <= 0) return { mode: 'chips', where, visible: total, overflow: 0 }
|
|
117
|
+
|
|
118
|
+
const { entries: ordered, dividerAfter } = orderEntries(input.legendItems, input.groups === true)
|
|
119
|
+
const chipFit = entriesWithinRows(ordered, width, LEGEND_ROWS[where])
|
|
120
|
+
const allChipsFit = chipFit >= total
|
|
121
|
+
|
|
122
|
+
// Chips whenever every entry fits one header row; dots is a header-only FALLBACK for when chips
|
|
123
|
+
// do not fit, and only while the card is short or the container itself is compact
|
|
124
|
+
// (`docs/CHARTS-SPEC.md` §5, Legend). A band legend always stays 'chips'.
|
|
125
|
+
const wantsDots =
|
|
126
|
+
where === 'header' && !allChipsFit && (input.cardShort === true || containerClass === 'compact')
|
|
127
|
+
|
|
128
|
+
if (!wantsDots) {
|
|
129
|
+
// Re-measured with the `All N` chip's own width reserved whenever there IS overflow — `chipFit`
|
|
130
|
+
// above is deliberately the un-reserved number (the `allChipsFit` check needs the answer to
|
|
131
|
+
// "does everything fit with no chip at all"), so it is not reused for the final count here.
|
|
132
|
+
const fitted = allChipsFit ? chipFit : entriesWithinChipRows(ordered, width, LEGEND_ROWS[where])
|
|
133
|
+
const { visible, overflow } = rollUp(total, fitted)
|
|
134
|
+
return { mode: 'chips', where, visible, overflow }
|
|
135
|
+
}
|
|
136
|
+
const dotFit = entriesWithinDotRows(ordered, width, LEGEND_ROWS[where], dividerAfter)
|
|
137
|
+
const { visible, overflow } = rollUp(total, dotFit)
|
|
138
|
+
return { mode: 'dots', where, visible, overflow }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compact and micro plots are tight: y labels move inside and terminal x labels anchor inward. */
|
|
142
|
+
export function isTightClass(containerClass: ContainerClass): boolean {
|
|
143
|
+
return containerClass === 'compact' || containerClass === 'micro'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* At a tight container class the y labels move inside the plot (micro: no y axis at all), so the
|
|
148
|
+
* left gutter drops to its floor. Not-tight, no left axis, or an explicit `margin.left` all keep
|
|
149
|
+
* the axis outside — the single implementation `CartesianChart`/`useBandPlot` both call.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveYPlacement(
|
|
152
|
+
containerClass: ContainerClass,
|
|
153
|
+
opts: { hasLeftAxis?: boolean | undefined; marginOverrideLeft?: number | undefined } = {},
|
|
154
|
+
): 'outside' | 'inside' | 'none' {
|
|
155
|
+
const { hasLeftAxis = true, marginOverrideLeft } = opts
|
|
156
|
+
if (!isTightClass(containerClass) || !hasLeftAxis || marginOverrideLeft !== undefined)
|
|
157
|
+
return 'outside'
|
|
158
|
+
return containerClass === 'micro' ? 'none' : 'inside'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Left-margin floor once `resolveYPlacement` moves the labels INSIDE the plot: just enough for the
|
|
163
|
+
* axis line and a hair of breathing room, not the outside axis's full label-width floor. Passing
|
|
164
|
+
* the tier's own `margin.left` here reserves a gutter for a label that no longer paints.
|
|
165
|
+
*/
|
|
166
|
+
export const INSIDE_Y_FLOOR = 4
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The `autoMargin` FLOOR to measure against, once `resolveYPlacement` has decided where the left
|
|
170
|
+
* axis paints — an axis placed `'outside'` keeps the tier's ordinary margin floor; one placed
|
|
171
|
+
* `'inside'` (or dropped, `'none'`) paints IN the plot, so the outside label-width floor is dead
|
|
172
|
+
* space on that side and only {@link INSIDE_Y_FLOOR} applies. `CartesianChart` and `useBandPlot`
|
|
173
|
+
* used to each spell this same ternary out by hand.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveMarginFloor(
|
|
176
|
+
yPlacement: 'outside' | 'inside' | 'none',
|
|
177
|
+
tierMargin: ChartMargin,
|
|
178
|
+
): ChartMargin {
|
|
179
|
+
return yPlacement === 'outside' ? tierMargin : { ...tierMargin, left: INSIDE_Y_FLOOR }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const COMPACT_UNITS = [
|
|
183
|
+
[1e12, 'T'],
|
|
184
|
+
[1e9, 'B'],
|
|
185
|
+
[1e6, 'M'],
|
|
186
|
+
[1e3, 'k'],
|
|
187
|
+
] as const
|
|
188
|
+
|
|
189
|
+
const roundTenth = (value: number): number => Math.round(value * 10) / 10
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Ladder step 1: `12500` -> `12.5k`, `1_200_000` -> `1.2M`; `null` below 1000 or non-finite. Rounds
|
|
193
|
+
* BEFORE choosing the unit's suffix, so `999_950` reads `1M`, never `1000k`; the largest unit caps.
|
|
194
|
+
*/
|
|
195
|
+
export function compactNumber(value: number): string | null {
|
|
196
|
+
const abs = Math.abs(value)
|
|
197
|
+
if (!Number.isFinite(value) || abs < 1000) return null
|
|
198
|
+
let index = COMPACT_UNITS.findIndex(([unit]) => abs >= unit)
|
|
199
|
+
let scaled = roundTenth(abs / COMPACT_UNITS[index]![0])
|
|
200
|
+
if (scaled >= 1000 && index > 0) {
|
|
201
|
+
index -= 1
|
|
202
|
+
scaled = roundTenth(abs / COMPACT_UNITS[index]![0])
|
|
203
|
+
}
|
|
204
|
+
return `${value < 0 ? '-' : ''}${scaled}${COMPACT_UNITS[index]![1]}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Ladder step 1's rule: compact only when the longest y label is over 4 chars. */
|
|
208
|
+
export function shouldCompactYLabels(labels: readonly string[]): boolean {
|
|
209
|
+
return labels.some((label) => label.length > COMPACT_LABEL_CHARS)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Ladder step 2 + the `VX.minPlotHeight` guard: one tick per ~44px, 2 when the plot is under the floor. */
|
|
213
|
+
export function yTickCount(plotHeight: number): number {
|
|
214
|
+
if (plotHeight < VX.minPlotHeight) return 2
|
|
215
|
+
return clamp(Math.round(plotHeight / TICK_SPACING), 2, 6)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Greedy word wrap by measured px; a single word wider than `maxPx` keeps its own line. */
|
|
219
|
+
export function wrapLabel(label: string, maxPx: number, fontPx: number): string[] {
|
|
220
|
+
const words = label.split(/\s+/).filter((word) => word !== '')
|
|
221
|
+
const lines: string[] = []
|
|
222
|
+
let current = ''
|
|
223
|
+
for (const word of words) {
|
|
224
|
+
const candidate = current === '' ? word : `${current} ${word}`
|
|
225
|
+
if (current !== '' && measureText(candidate, fontPx) > maxPx) {
|
|
226
|
+
lines.push(current)
|
|
227
|
+
current = word
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
current = candidate
|
|
231
|
+
}
|
|
232
|
+
if (current !== '') lines.push(current)
|
|
233
|
+
return lines.length > 0 ? lines : [label]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type XLabelPlan = {
|
|
237
|
+
wrap: boolean
|
|
238
|
+
rotate: 0 | 45
|
|
239
|
+
/** Lines the tallest label takes once wrapped (1 when it does not). */
|
|
240
|
+
lines: number
|
|
241
|
+
/** Px one tick label needs: measured width (wrapped width when wrapping) plus the gap. */
|
|
242
|
+
labelPx: number
|
|
243
|
+
/** Width labels wrap to; `0` when they do not wrap. */
|
|
244
|
+
wrapPx: number
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Ladder step 4: x labels that do not all fit side by side wrap — categorical (band) domains
|
|
249
|
+
* only, and only when wrapping shows EVERY key. A wrap that would still drop keys buys nothing
|
|
250
|
+
* (it costs a line of bottom gutter to thin the same way flat already would), so it falls back to
|
|
251
|
+
* flat/thinned instead. Rotating is the last resort, reached only once even a full wrap can't fit.
|
|
252
|
+
*/
|
|
253
|
+
export function planXLabels(input: {
|
|
254
|
+
labels: readonly string[]
|
|
255
|
+
plotWidth: number
|
|
256
|
+
fontPx: number
|
|
257
|
+
categorical: boolean
|
|
258
|
+
}): XLabelPlan {
|
|
259
|
+
const { labels, plotWidth, fontPx, categorical } = input
|
|
260
|
+
const count = labels.length
|
|
261
|
+
const widest = labels.reduce((max, label) => Math.max(max, measureText(label, fontPx)), 0)
|
|
262
|
+
const flatPx = widest + X_LABEL_GAP
|
|
263
|
+
const fit = plotWidth > 0 && widest > 0 ? Math.floor(plotWidth / flatPx) : count
|
|
264
|
+
const flat = {
|
|
265
|
+
wrap: false,
|
|
266
|
+
rotate: 0,
|
|
267
|
+
lines: 1,
|
|
268
|
+
labelPx: flatPx,
|
|
269
|
+
wrapPx: 0,
|
|
270
|
+
} as const satisfies XLabelPlan
|
|
271
|
+
if (!categorical || count <= fit || plotWidth <= 0) return flat
|
|
272
|
+
|
|
273
|
+
const widestWord = labels.reduce(
|
|
274
|
+
(max, label) => Math.max(max, ...label.split(/\s+/).map((word) => measureText(word, fontPx))),
|
|
275
|
+
0,
|
|
276
|
+
)
|
|
277
|
+
const wrapPx = Math.min(widest, Math.max(widestWord, Math.floor(plotWidth / count) - X_LABEL_GAP))
|
|
278
|
+
const wrapped = labels.map((label) => wrapLabel(label, wrapPx, fontPx))
|
|
279
|
+
const lines = wrapped.reduce((max, parts) => Math.max(max, parts.length), 1)
|
|
280
|
+
if (lines === 1) {
|
|
281
|
+
// Nothing to wrap (single words): rotating is the only remaining answer.
|
|
282
|
+
return count > 2 * Math.max(fit, 1) ? { ...flat, rotate: 45 } : flat
|
|
283
|
+
}
|
|
284
|
+
const wrappedWidest = wrapped.reduce(
|
|
285
|
+
(max, parts) => Math.max(max, ...parts.map((part) => measureText(part, fontPx))),
|
|
286
|
+
0,
|
|
287
|
+
)
|
|
288
|
+
const wrappedFit = Math.max(Math.floor(plotWidth / (wrappedWidest + X_LABEL_GAP)), 1)
|
|
289
|
+
if (wrappedFit >= count) {
|
|
290
|
+
return {
|
|
291
|
+
wrap: true,
|
|
292
|
+
rotate: 0,
|
|
293
|
+
lines,
|
|
294
|
+
labelPx: wrappedWidest + X_LABEL_GAP,
|
|
295
|
+
wrapPx,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return count > 2 * wrappedFit ? { ...flat, rotate: 45 } : flat
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The container class: the measured width's, or the viewport-implied one while unmeasured. */
|
|
302
|
+
export function resolveFrameClass(input: {
|
|
303
|
+
frameW: number
|
|
304
|
+
sizeClass: keyof typeof SIZE_CLASSES
|
|
305
|
+
}): ContainerClass {
|
|
306
|
+
return input.frameW > 0
|
|
307
|
+
? resolveContainerClass(input.frameW)
|
|
308
|
+
: containerClassFromSizeClass(input.sizeClass)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function resolveChartLayout(input: ChartLayoutInput): ChartLayout {
|
|
312
|
+
const containerClass = resolveFrameClass(input)
|
|
313
|
+
return {
|
|
314
|
+
containerClass,
|
|
315
|
+
height: resolveHeight(input, containerClass),
|
|
316
|
+
legend: resolveLegend(input, containerClass),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -1,37 +1,82 @@
|
|
|
1
|
-
import { createContext, useContext } from 'react'
|
|
1
|
+
import { createContext, useContext, useSyncExternalStore } from 'react'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
3
|
+
import { useMediaQuery } from '../../common/use-media-query'
|
|
4
|
+
import type { ContainerClass } from '../../tokens/size-classes'
|
|
5
|
+
import { chartMetrics } from './chart-frame-layout'
|
|
6
|
+
import type { ChartTierMetrics } from './chart-frame-layout'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* Ambient
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Ambient container class. `ChartFrame` publishes the class it resolved (`resolveChartLayout`:
|
|
10
|
+
* its own MEASURED width, or the viewport-implied class before the first measurement); the axes,
|
|
11
|
+
* the legend, the crosshair dots and the tooltip read their metrics back without every kind
|
|
12
|
+
* threading a prop it does not otherwise care about.
|
|
10
13
|
*
|
|
11
|
-
* Default `'
|
|
12
|
-
* `TooltipRow` in a consumer's own tooltip) keeps
|
|
14
|
+
* Default `'regular'` (desktop metrics), so a primitive rendered outside a `ChartFrame` (a
|
|
15
|
+
* hand-composed plot, a `TooltipRow` in a consumer's own tooltip) keeps today's sizes.
|
|
13
16
|
*/
|
|
14
|
-
const
|
|
17
|
+
const ChartContainerClassContext = createContext<ContainerClass>('regular')
|
|
15
18
|
|
|
16
19
|
export type ChartTierProviderProps = {
|
|
17
|
-
|
|
20
|
+
containerClass: ContainerClass
|
|
18
21
|
children: ReactNode
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
/** Publishes a resolved
|
|
22
|
-
export function ChartTierProvider({
|
|
23
|
-
return
|
|
24
|
+
/** Publishes a resolved container class to the subtree. Mounted once, by `ChartFrame`. */
|
|
25
|
+
export function ChartTierProvider({ containerClass, children }: ChartTierProviderProps): ReactNode {
|
|
26
|
+
return (
|
|
27
|
+
<ChartContainerClassContext.Provider value={containerClass}>
|
|
28
|
+
{children}
|
|
29
|
+
</ChartContainerClassContext.Provider>
|
|
30
|
+
)
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
/** The
|
|
27
|
-
export function
|
|
28
|
-
return useContext(
|
|
33
|
+
/** The ambient container class (`'regular'` outside a `ChartFrame`). Internal. */
|
|
34
|
+
export function useChartContainerClass(): ContainerClass {
|
|
35
|
+
return useContext(ChartContainerClassContext)
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
/** The resolved sizes for the ambient
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
38
|
+
/** The resolved sizes for the ambient container class — what every internal primitive wants. */
|
|
39
|
+
export function useChartMetrics(): ChartTierMetrics {
|
|
40
|
+
return chartMetrics(useChartContainerClass())
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const subscribeNothing = (): (() => void) => () => {}
|
|
44
|
+
|
|
45
|
+
/** Whether the primary pointer is coarse. Server snapshot `false`: hydration agrees, then updates. */
|
|
46
|
+
export function useCoarsePointer(): boolean {
|
|
47
|
+
return useMediaQuery('(pointer: coarse)', false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Viewport height is quantized so a mobile URL bar collapsing does not re-lay-out every chart. */
|
|
51
|
+
const VIEWPORT_H_STEP = 50
|
|
52
|
+
|
|
53
|
+
// One shared `resize` listener for the whole page (like `common/use-media-query.ts`'s one
|
|
54
|
+
// `MediaQueryList` per query) rather than every mounted `ChartFrame` installing its own — a
|
|
55
|
+
// dashboard with a dozen charts used to mean a dozen native listeners for the same value.
|
|
56
|
+
const viewportHListeners = new Set<() => void>()
|
|
57
|
+
let viewportHListenerAttached = false
|
|
58
|
+
|
|
59
|
+
function ensureViewportHListener(): void {
|
|
60
|
+
if (viewportHListenerAttached || typeof window === 'undefined') return
|
|
61
|
+
viewportHListenerAttached = true
|
|
62
|
+
window.addEventListener('resize', () => {
|
|
63
|
+
for (const listener of viewportHListeners) listener()
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subscribeViewportH(onChange: () => void): () => void {
|
|
68
|
+
if (typeof window === 'undefined') return subscribeNothing()
|
|
69
|
+
ensureViewportHListener()
|
|
70
|
+
viewportHListeners.add(onChange)
|
|
71
|
+
return () => viewportHListeners.delete(onChange)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readViewportH(): number {
|
|
75
|
+
if (typeof window === 'undefined') return 0
|
|
76
|
+
return Math.round(window.innerHeight / VIEWPORT_H_STEP) * VIEWPORT_H_STEP
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Viewport height in px (`0` on the server). Only the coarse-pointer height clamp reads it. */
|
|
80
|
+
export function useViewportHeight(): number {
|
|
81
|
+
return useSyncExternalStore(subscribeViewportH, readViewportH, () => 0)
|
|
82
|
+
}
|
package/src/charts/series.ts
CHANGED
|
@@ -85,7 +85,10 @@ export type ChartLegendConfig = {
|
|
|
85
85
|
placement?: LegendPlacement
|
|
86
86
|
/** Visually separate role: series | overlay | reference. */
|
|
87
87
|
groups?: boolean
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* @deprecated Removed in 1.32.0 — the legend fits by measured width and folds overflow into an
|
|
90
|
+
* `All N` disclosure on its own. An explicit value still wins this release (entry count).
|
|
91
|
+
*/
|
|
89
92
|
maxRows?: number
|
|
90
93
|
/** Clicking an entry hides that series. Default: on whenever there is more than one entry. */
|
|
91
94
|
toggle?: boolean
|
|
@@ -12,47 +12,123 @@ const X_TICK_LABEL_GAP = 8
|
|
|
12
12
|
* `useBandPlot` and `DualPanel` — the three call sites that used to reimplement this formula.
|
|
13
13
|
*
|
|
14
14
|
* `fontPx` defaults to `VX.axisFont`; a phone-tier chart passes its own smaller tick font
|
|
15
|
-
* (`
|
|
15
|
+
* (`chartMetrics().axisFont`) so the spacing is derived from the size actually painted.
|
|
16
16
|
*/
|
|
17
17
|
export function xLabelPxFor(labels: string[], fontPx: number = VX.axisFont): number {
|
|
18
18
|
return maxTextWidth(labels, fontPx) + X_TICK_LABEL_GAP
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The horizontal room one 45°-rotated x label takes: its projected box (`cos 45°` of the width plus
|
|
23
|
+
* one line box) plus the gap. A tilted label still competes with its neighbour for this much, so
|
|
24
|
+
* thinning by the bare `VX.minPxPerTick` floor let two of them touch as soon as the plot widened.
|
|
25
|
+
*/
|
|
26
|
+
export function rotatedXLabelPx(labels: string[], fontPx: number = VX.axisFont): number {
|
|
27
|
+
return (
|
|
28
|
+
Math.ceil(COS_45 * (maxTextWidth(labels, fontPx) + Math.ceil(fontPx * 1.35))) + X_TICK_LABEL_GAP
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* Pick evenly-spaced tick values that fit the available width.
|
|
23
34
|
*
|
|
24
35
|
* `labelPx` is the width one formatted label actually needs (measured, plus the gap it wants from
|
|
25
|
-
* its neighbour) and
|
|
26
|
-
*
|
|
27
|
-
* `Mar 08 14:00`
|
|
28
|
-
* "measure what you paint" law
|
|
36
|
+
* its neighbour) and REPLACES the `VX.minPxPerTick` constant whenever it is supplied, in either
|
|
37
|
+
* direction — a wide label needs more room than the constant assumed (a `formatX` returning
|
|
38
|
+
* `Mar 08 14:00` used to overlap at every width, the one side of the chart that did not follow
|
|
39
|
+
* §1's "measure what you paint" law), and a narrow one (single-digit band labels) can pack in
|
|
40
|
+
* MORE ticks than the constant would allow, which the floor used to suppress (R2C-11). Omit it and
|
|
41
|
+
* the constant is what governs, same as before.
|
|
29
42
|
*
|
|
30
43
|
* The final key is still appended when the step misses the last index — a thinned axis would
|
|
31
44
|
* otherwise paint no label at the right edge at all — but it no longer prints ON TOP of its
|
|
32
45
|
* neighbour. An appended tick lands a PARTIAL step from the last one on the grid, so at any tick
|
|
33
46
|
* count wide enough labels overlap there; measured at `/charts-stress` block (f1), `Mar 13 14:00`
|
|
34
47
|
* and `Mar 14 14:00` printed over each other at 1440px. When that gap is narrower than one label,
|
|
35
|
-
* the grid tick before it is dropped instead.
|
|
36
|
-
*
|
|
48
|
+
* the grid tick before it is dropped instead.
|
|
49
|
+
*
|
|
50
|
+
* `anchorTerminals` (default false) mirrors the SAME flag `autoMargin`/`Axes.tsx` take: when the
|
|
51
|
+
* axis anchors index 0 and the last index at their TEXT edge rather than their centre
|
|
52
|
+
* (`terminalAnchor`), each reaches a FULL label's width toward its neighbour instead of half, so
|
|
53
|
+
* the plain per-tick pitch above (sized for two centred labels) isn't enough at either boundary —
|
|
54
|
+
* `Bars` with 30 daily "Jan DD HH:MM" keys overlapped there at phone width even though the interior
|
|
55
|
+
* pitch was fine (R2C-6). The SAME rule then applies, symmetrically, to the tick right after
|
|
56
|
+
* index 0. 1.5× is a deliberately generous (cheap, simple) stand-in for "one label plus half a
|
|
57
|
+
* label" rather than measuring the split precisely; it's skipped when `labelPx` is only the bare
|
|
58
|
+
* `VX.minPxPerTick` floor, which has no relationship to how wide a label actually is. Index 0
|
|
59
|
+
* always survives (never the one dropped here) and `last` is always (re-)added below, so dropping
|
|
60
|
+
* any OTHER index never breaks the "at least 2 ticks" floor.
|
|
37
61
|
*/
|
|
38
|
-
export function smartTicks(
|
|
62
|
+
export function smartTicks(
|
|
63
|
+
dates: string[],
|
|
64
|
+
xMax: number,
|
|
65
|
+
labelPx?: number,
|
|
66
|
+
anchorTerminals = false,
|
|
67
|
+
): string[] {
|
|
39
68
|
if (dates.length === 0) return []
|
|
40
|
-
|
|
69
|
+
// A non-positive `labelPx` (a caller's own arithmetic gone wrong, or an explicit 0) is not "no
|
|
70
|
+
// measurement" — `labelPx ?? VX.minPxPerTick` let it through as-is, and `xMax / 0` then floors
|
|
71
|
+
// `maxTicks` to `Infinity`, which used to make EVERY tick "fit" and skip thinning entirely.
|
|
72
|
+
const perTick = labelPx !== undefined && labelPx > 0 ? labelPx : VX.minPxPerTick
|
|
41
73
|
const maxTicks = Math.max(2, Math.floor(xMax / perTick))
|
|
42
|
-
if (dates.length <= maxTicks) return dates
|
|
43
|
-
const step = Math.ceil(dates.length / maxTicks)
|
|
44
74
|
const last = dates.length - 1
|
|
75
|
+
// `scalePoint({ padding: 0.5 })` spreads N points over `xMax`, so one index is `xMax / N` wide.
|
|
76
|
+
const pxPerIndex = dates.length > 0 ? xMax / dates.length : 0
|
|
77
|
+
// Only a REAL measured label (not the bare `VX.minPxPerTick` floor, which knows nothing about how
|
|
78
|
+
// wide a label actually is) earns the wider terminal clearance below.
|
|
79
|
+
const anchored = anchorTerminals && labelPx !== undefined && labelPx > 0
|
|
45
80
|
|
|
46
81
|
const keep = new Set<number>()
|
|
47
|
-
|
|
82
|
+
if (dates.length <= maxTicks) {
|
|
83
|
+
// Every date already fits with no thinning — round 3: this used to `return dates` here,
|
|
84
|
+
// skipping the terminal-clearance pass below entirely. A terminal-anchored label still reaches
|
|
85
|
+
// a FULL label's width toward its neighbour (not half, like a centred one), which the plain
|
|
86
|
+
// per-tick spacing above does not itself guarantee, so the same pass has to run here too.
|
|
87
|
+
for (let i = 0; i <= last; i += 1) keep.add(i)
|
|
88
|
+
} else {
|
|
89
|
+
const step = Math.ceil(dates.length / maxTicks)
|
|
90
|
+
for (let i = 0; i <= last; i += step) keep.add(i)
|
|
48
91
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
const BOUNDARY_FACTOR = 1.5
|
|
93
|
+
const boundaryPx = anchored ? perTick * BOUNDARY_FACTOR : perTick
|
|
94
|
+
// The final key is appended unconditionally, even when the step misses it — but only dropping
|
|
95
|
+
// the grid tick right before it when the two would otherwise sit closer than one tick's width.
|
|
96
|
+
// This is the "would an APPEND overlap" concern; it does nothing when the step already lands on
|
|
97
|
+
// `last` (nothing is being appended), which is the gap the anchored pass below closes.
|
|
98
|
+
const lastOnGrid = Math.floor(last / step) * step
|
|
99
|
+
if (lastOnGrid !== last) {
|
|
100
|
+
const gapPx = (last - lastOnGrid) * pxPerIndex
|
|
101
|
+
if (lastOnGrid > 0 && gapPx > 0 && gapPx < boundaryPx) keep.delete(lastOnGrid)
|
|
102
|
+
keep.add(last)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (anchored && keep.size > 1) {
|
|
107
|
+
const BOUNDARY_FACTOR = 1.5
|
|
108
|
+
const boundaryPx = perTick * BOUNDARY_FACTOR
|
|
109
|
+
// START: the kept index right after 0 needs a FULL label's clearance from it, not the plain
|
|
110
|
+
// per-tick pitch a centred label would settle for.
|
|
111
|
+
const sorted = [...keep].toSorted((a, b) => a - b)
|
|
112
|
+
const second = sorted[1]
|
|
113
|
+
if (second !== undefined && second !== last && second * pxPerIndex < boundaryPx) {
|
|
114
|
+
keep.delete(second)
|
|
115
|
+
}
|
|
116
|
+
// END: the same check, mirrored, against whatever is now the second-to-last kept index —
|
|
117
|
+
// computed AFTER the START drop above (not from the original grid), and unconditional of
|
|
118
|
+
// whether `last` was naturally on the grid or had to be appended (round 3's fix: previously this
|
|
119
|
+
// only ran inside the `lastOnGrid !== last` branch above, so a grid that already landed on
|
|
120
|
+
// `last` skipped it — the 13-dates/60px-label/260px-width counter-example this closes: step 4
|
|
121
|
+
// lands on index 12, leaving its neighbour at index 8 only 80px away, under the 90px a
|
|
122
|
+
// full-width terminal label needs).
|
|
123
|
+
const afterHead = [...keep].toSorted((a, b) => a - b)
|
|
124
|
+
const secondLast = afterHead[afterHead.length - 2]
|
|
125
|
+
if (
|
|
126
|
+
secondLast !== undefined &&
|
|
127
|
+
secondLast !== 0 &&
|
|
128
|
+
(last - secondLast) * pxPerIndex < boundaryPx
|
|
129
|
+
) {
|
|
130
|
+
keep.delete(secondLast)
|
|
131
|
+
}
|
|
56
132
|
}
|
|
57
133
|
|
|
58
134
|
return dates.filter((_, i) => keep.has(i))
|
package/src/common/errors.ts
CHANGED
|
@@ -48,10 +48,11 @@ export function deprecatedProp(
|
|
|
48
48
|
oldProp: string,
|
|
49
49
|
newProp: string,
|
|
50
50
|
removeIn: string,
|
|
51
|
+
note?: string,
|
|
51
52
|
): string {
|
|
52
53
|
return message(
|
|
53
54
|
component,
|
|
54
|
-
`prop "${oldProp}" is deprecated — use "${newProp}" instead. "${oldProp}" is removed in ${removeIn}
|
|
55
|
+
`prop "${oldProp}" is deprecated — use "${newProp}" instead. "${oldProp}" is removed in ${removeIn}.${note === undefined ? '' : ` ${note}`}`,
|
|
55
56
|
)
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -69,6 +70,19 @@ export function ignoredProp(component: string, prop: string, because: string): s
|
|
|
69
70
|
return message(component, `prop "${prop}" is ignored because ${because}`)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A numeric `height` the caller stated that, once the legend band is taken out, leaves the plot
|
|
75
|
+
* under the floor. The frame keeps the plot at the floor and grows past the number, so the chart
|
|
76
|
+
* is taller than the caller asked for and nothing else says why.
|
|
77
|
+
*/
|
|
78
|
+
export function plotBelowFloor(component: string, height: number, floor: number): string {
|
|
79
|
+
return message(
|
|
80
|
+
component,
|
|
81
|
+
`prop "height" (${height}) leaves less than the ${floor}px plot floor once the legend band is ` +
|
|
82
|
+
'subtracted, so the frame grows past it. Raise the height or drop the legend.',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
87
|
* An imperative call needs a layer that is not there — the optional peer is not installed, or the
|
|
74
88
|
* mount that subscribes to its event bus is disabled. Both halves are named because the remedy
|
|
@@ -86,6 +100,16 @@ export function duplicateMount(component: string): string {
|
|
|
86
100
|
)
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
/** Two frames inside one `ChartCard` both want the header legend slot (`docs/waves/PLAN.md` wave
|
|
104
|
+
* 3, P2-9) — only the first to mount keeps it, every later one falls back to its own band legend
|
|
105
|
+
* rather than silently overwriting the portal. */
|
|
106
|
+
export function legendSlotContention(component: string): string {
|
|
107
|
+
return message(
|
|
108
|
+
component,
|
|
109
|
+
'more than one chart in this ChartCard wants the header legend slot — only the first keeps it, the rest fall back to their own band legend.',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
// ── Transport-agnostic error decoding ────────────────────────────────────────────────────────────
|
|
90
114
|
//
|
|
91
115
|
// Deliberately in this module, with NO `@tanstack/react-query` import — `dashboard/query-state.tsx`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pointer-tier hit floors, in px — WCAG 2.5.8 Target Size Minimum (24) and 2.5.5 Target Size
|
|
3
|
+
* Enhanced (44). One home for the number `--vx-hit` resolves to under `(pointer: coarse)` and for
|
|
4
|
+
* the components that compare a MEASURED px against it (a virtual list's row height, a column-fold
|
|
5
|
+
* toggle's width, the chart legend's dot pitch). Deliberately not re-exported through
|
|
6
|
+
* `common/index.ts`: a CSS-var's numeric value is not a public symbol.
|
|
7
|
+
*/
|
|
8
|
+
export const HIT_FINE = 24
|
|
9
|
+
export const HIT_COARSE = 44
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* nothing on the server" warning. The branch reads a global that cannot change between renders, so
|
|
6
6
|
* the hook identity is stable across every render of every caller.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Lives in `common/` rather than beside the shell because both halves use it — the shell chrome, the
|
|
9
|
+
* provider, and the Mantine-free measured-fold core — and `common/` is the one module both may
|
|
10
|
+
* import from. One module rather than one `const` per file. Not exported from any barrel; import it
|
|
11
|
+
* by path.
|
|
11
12
|
*/
|
|
12
13
|
import { useEffect, useLayoutEffect } from 'react'
|
|
13
14
|
|