basalt-ui 1.30.0 → 1.30.1
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/CHANGELOG.md +8 -0
- package/MIGRATING.md +240 -30
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.d.ts +5 -3
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +4 -2
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +4 -2
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +4 -2
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +5 -2
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +4 -2
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +4 -2
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +4 -2
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.d.ts +4 -1
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.d.ts +9 -8
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +21 -14
- package/dist/charts/primitives/chart-frame-layout.d.ts +63 -9
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +30 -6
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/BandStrip.tsx +5 -3
- package/src/charts/kinds/Bars.tsx +4 -2
- package/src/charts/kinds/Donut.tsx +4 -2
- package/src/charts/kinds/DualPanel.tsx +4 -2
- package/src/charts/kinds/Heatmap.tsx +5 -2
- package/src/charts/kinds/MirroredBars.tsx +4 -2
- package/src/charts/kinds/MultiLine.tsx +4 -2
- package/src/charts/kinds/StackedArea.tsx +4 -2
- package/src/charts/kinds/ZonedLine.tsx +4 -2
- package/src/charts/primitives/CartesianChart.tsx +4 -1
- package/src/charts/primitives/ChartFrame.tsx +33 -34
- package/src/charts/primitives/chart-frame-layout.ts +89 -16
|
@@ -6,6 +6,7 @@ import type { BasaltProps } from '../../common/props'
|
|
|
6
6
|
import type { CursorResolution } from '../cursor/resolve'
|
|
7
7
|
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
8
8
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
9
|
+
import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
|
|
9
10
|
import type { XZoneSpec } from '../primitives/XZoneRects'
|
|
10
11
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
11
12
|
import type { ChartState } from '../primitives/ChartPending'
|
|
@@ -15,8 +16,9 @@ import { VX } from '../../tokens'
|
|
|
15
16
|
|
|
16
17
|
export type MultiLineProps<T> = BasaltProps & {
|
|
17
18
|
data: T[]
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
|
|
20
|
+
* unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
|
|
21
|
+
height?: number | ResponsiveChartHeight
|
|
20
22
|
chartId: string
|
|
21
23
|
/** Extracts the x-axis category (date string) from a data point. */
|
|
22
24
|
getX: (d: T) => string
|
|
@@ -5,6 +5,7 @@ import type { BasaltProps } from '../../common/props'
|
|
|
5
5
|
import type { CursorResolution } from '../cursor/resolve'
|
|
6
6
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
7
7
|
import type { AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
8
|
+
import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
|
|
8
9
|
import type { ChartState } from '../primitives/ChartPending'
|
|
9
10
|
import { curveFor } from '../series'
|
|
10
11
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
@@ -12,8 +13,9 @@ import { isDev } from '../../common/is-dev'
|
|
|
12
13
|
|
|
13
14
|
export type StackedAreaProps<T> = BasaltProps & {
|
|
14
15
|
data: T[]
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
|
|
17
|
+
* unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
|
|
18
|
+
height?: number | ResponsiveChartHeight
|
|
17
19
|
chartId: string
|
|
18
20
|
getX: (d: T) => string
|
|
19
21
|
/** The stacked bands, bottom to top — the single source of truth for color, legend, and tooltip
|
|
@@ -7,6 +7,7 @@ import type { CursorResolution } from '../cursor/resolve'
|
|
|
7
7
|
import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
|
|
8
8
|
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
9
9
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
10
|
+
import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
|
|
10
11
|
import type { XZoneSpec } from '../primitives/XZoneRects'
|
|
11
12
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
12
13
|
import type { ChartState } from '../primitives/ChartPending'
|
|
@@ -31,8 +32,9 @@ export type ZonedLineRefLine = {
|
|
|
31
32
|
|
|
32
33
|
export type ZonedLineProps<T> = BasaltProps & {
|
|
33
34
|
data: T[]
|
|
34
|
-
/**
|
|
35
|
-
|
|
35
|
+
/** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
|
|
36
|
+
* unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
|
|
37
|
+
height?: number | ResponsiveChartHeight
|
|
36
38
|
chartId: string
|
|
37
39
|
/** Extracts the x-axis category (date string) from a data point. */
|
|
38
40
|
getX: (d: T) => string
|
|
@@ -17,6 +17,7 @@ import { fmtAxisDate } from '../utils/format'
|
|
|
17
17
|
import { autoXLabelRotate, smartTicks, smartTicksEvery, xLabelPxFor } from '../utils/ticks'
|
|
18
18
|
import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
|
|
19
19
|
import { ChartFrame, resolveLegend } from './ChartFrame'
|
|
20
|
+
import type { ResponsiveChartHeight } from './ChartFrame'
|
|
20
21
|
import { useChartTierMetrics } from './chart-tier'
|
|
21
22
|
import type { ChartState } from './ChartPending'
|
|
22
23
|
import { ChartTooltipFloat, TooltipBody, TooltipHeader, TooltipRow } from './ChartTooltip'
|
|
@@ -209,7 +210,9 @@ export type CartesianChartProps<T> = BasaltProps & {
|
|
|
209
210
|
/** X-range bands (time windows), drawn behind the marks. */
|
|
210
211
|
xZones?: XZoneSpec[]
|
|
211
212
|
refLines?: { value: number; color: string; dashed?: boolean; axis?: 'left' | 'right' }[]
|
|
212
|
-
|
|
213
|
+
/** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
|
|
214
|
+
* unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
|
|
215
|
+
height?: number | ResponsiveChartHeight
|
|
213
216
|
aspectRatio?: number
|
|
214
217
|
fill?: boolean
|
|
215
218
|
legend?: ChartLegendConfig | false
|
|
@@ -6,10 +6,9 @@ import { useChartSize } from '../hooks/useChartSize'
|
|
|
6
6
|
import { deriveLegend } from '../series'
|
|
7
7
|
import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
|
|
8
8
|
import {
|
|
9
|
-
legendEntryCap,
|
|
10
9
|
resolveChartTier,
|
|
11
10
|
resolveFrameHeight,
|
|
12
|
-
|
|
11
|
+
resolveLegendRollup,
|
|
13
12
|
resolvePlotRect,
|
|
14
13
|
} from './chart-frame-layout'
|
|
15
14
|
import type { ResponsiveChartHeight } from './chart-frame-layout'
|
|
@@ -20,7 +19,12 @@ import type { ChartState } from './ChartPending'
|
|
|
20
19
|
|
|
21
20
|
/** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
|
|
22
21
|
* through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
|
|
23
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
legendEntryCap,
|
|
24
|
+
resolveLegendRollup,
|
|
25
|
+
resolvePlotRect,
|
|
26
|
+
resolveChartTier,
|
|
27
|
+
} from './chart-frame-layout'
|
|
24
28
|
export type { ResponsiveChartHeight } from './chart-frame-layout'
|
|
25
29
|
|
|
26
30
|
const DEFAULT_HEIGHT = 240
|
|
@@ -34,13 +38,14 @@ export type ChartFrameLegend = {
|
|
|
34
38
|
/**
|
|
35
39
|
* Wrap cap → "+N more" rollup at high cardinality.
|
|
36
40
|
*
|
|
37
|
-
* An explicit value WINS OUTRIGHT —
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* ({@link
|
|
41
|
+
* An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
|
|
42
|
+
* AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
|
|
43
|
+
* the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
|
|
44
|
+
* 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
|
|
45
|
+
* nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
|
|
46
|
+
* number a caller stated, so under `fill` the PLOT yields the height instead
|
|
47
|
+
* ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
|
|
48
|
+
* gets what is left — visible, and yours.
|
|
44
49
|
*/
|
|
45
50
|
maxRows?: number
|
|
46
51
|
/** Visually separate role: series | overlay | reference. */
|
|
@@ -232,38 +237,32 @@ export function ChartFrame({
|
|
|
232
237
|
const sideLegendWidth = legendVisible && vertical ? legendW : 0
|
|
233
238
|
const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
|
|
234
239
|
|
|
240
|
+
const legendItems = legend === false ? [] : deriveLegend(series)
|
|
241
|
+
const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
|
|
242
|
+
|
|
243
|
+
// The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
|
|
244
|
+
// cases and why the composition, not either half, is what shipped broken). A fixed-height frame
|
|
245
|
+
// grows around its legend; a `fill` one cannot, so its legend rolls up to the measured fit
|
|
246
|
+
// instead of eating the plot — unless the caller stated a number, which nothing trims and the
|
|
247
|
+
// plot pays for. Top/bottom only: a side legend costs width, not height.
|
|
248
|
+
const { maxRows, legendWins } = resolveLegendRollup({
|
|
249
|
+
...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
|
|
250
|
+
tier,
|
|
251
|
+
fillBand: legendVisible && fill && !vertical,
|
|
252
|
+
items: legendItems,
|
|
253
|
+
containerW,
|
|
254
|
+
available: resolvedHeight - VX.minPlotHeight,
|
|
255
|
+
})
|
|
256
|
+
|
|
235
257
|
const plot = resolvePlotRect({
|
|
236
258
|
containerW,
|
|
237
259
|
resolvedHeight,
|
|
238
260
|
minWidth,
|
|
239
261
|
sideLegendWidth,
|
|
240
262
|
topBottomLegendHeight,
|
|
263
|
+
...(legendWins && { legendWins }),
|
|
241
264
|
})
|
|
242
265
|
|
|
243
|
-
const legendItems = legend === false ? [] : deriveLegend(series)
|
|
244
|
-
const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
|
|
245
|
-
|
|
246
|
-
// A fixed-height frame grows around its legend; a `fill` one cannot, so its legend rolls up
|
|
247
|
-
// instead of eating the plot (see `legendEntryCap`). Top/bottom only — a side legend costs
|
|
248
|
-
// width, not height.
|
|
249
|
-
// The resolved cap — the caller's if they named one, else the tier's DEFAULT, never both
|
|
250
|
-
// `Math.min`'d (`resolveLegendMaxRows` owns that law and says why) — enters the fill measurement
|
|
251
|
-
// as the upper bound, so a phone-width `fill` frame can only ever roll up further, never less.
|
|
252
|
-
const callerMaxRows =
|
|
253
|
-
legend === false ? undefined : resolveLegendMaxRows({ callerMaxRows: legend.maxRows, tier })
|
|
254
|
-
|
|
255
|
-
const maxRows =
|
|
256
|
-
legend === false
|
|
257
|
-
? undefined
|
|
258
|
-
: fill && !vertical
|
|
259
|
-
? legendEntryCap({
|
|
260
|
-
items: legendItems,
|
|
261
|
-
containerW,
|
|
262
|
-
available: resolvedHeight - VX.minPlotHeight,
|
|
263
|
-
...(callerMaxRows !== undefined && { callerMaxRows }),
|
|
264
|
-
})
|
|
265
|
-
: callerMaxRows
|
|
266
|
-
|
|
267
266
|
const legendNode =
|
|
268
267
|
legend === false || resolvedState !== null ? null : (
|
|
269
268
|
<div ref={legendRef} style={legendWrapperStyle(vertical)}>
|
|
@@ -137,7 +137,7 @@ export type ChartTierMetrics = {
|
|
|
137
137
|
/** Floating tooltip `minWidth`, px. */
|
|
138
138
|
tooltipMinWidth: number
|
|
139
139
|
/** Default entry cap on the legend, or `undefined` for no default cap. An explicit
|
|
140
|
-
* `legend.maxRows`
|
|
140
|
+
* `legend.maxRows` replaces it outright ({@link resolveLegendMaxRows}). */
|
|
141
141
|
legendMaxRows: number | undefined
|
|
142
142
|
/** Per-side margin FLOORS (`VX.margin` is a floor, never a ceiling — §1). */
|
|
143
143
|
margin: ChartMargin
|
|
@@ -186,9 +186,14 @@ export function chartTierMetrics(tier: ChartTier): ChartTierMetrics {
|
|
|
186
186
|
* panel on a 1440px desktop is "a phone", so a six-series legend rolled up to two rows + `+4 more`
|
|
187
187
|
* with no way to say no. `margin` and `xLabelRotate` always had that escape; the legend did not.
|
|
188
188
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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.
|
|
192
197
|
*/
|
|
193
198
|
export function resolveLegendMaxRows(input: {
|
|
194
199
|
callerMaxRows?: number | undefined
|
|
@@ -197,6 +202,48 @@ export function resolveLegendMaxRows(input: {
|
|
|
197
202
|
return input.callerMaxRows ?? chartTierMetrics(input.tier).legendMaxRows
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 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.
|
|
214
|
+
*
|
|
215
|
+
* Three cases, in order:
|
|
216
|
+
* 1. The caller STATED a number → it is the cap, and on a `fill` band the plot yields
|
|
217
|
+
* (`legendWins`). Nothing measured or defaulted trims it.
|
|
218
|
+
* 2. No `fill` band → the tier's default (or none), because the frame simply grows.
|
|
219
|
+
* 3. A `fill` band with nothing stated → the measured fit, bounded by the tier's default.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveLegendRollup(input: {
|
|
222
|
+
/** The caller's own `legend.maxRows`. */
|
|
223
|
+
statedMaxRows?: number | undefined
|
|
224
|
+
tier: ChartTier
|
|
225
|
+
/** A visible top/bottom legend on a `fill` frame — the one shape whose box cannot grow. */
|
|
226
|
+
fillBand: boolean
|
|
227
|
+
items: readonly LegendEntry[]
|
|
228
|
+
containerW: number
|
|
229
|
+
/** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
|
|
230
|
+
available: number
|
|
231
|
+
}): { maxRows: number | undefined; legendWins: boolean } {
|
|
232
|
+
const { statedMaxRows, tier, fillBand, items, containerW, available } = input
|
|
233
|
+
if (statedMaxRows !== undefined) return { maxRows: statedMaxRows, legendWins: fillBand }
|
|
234
|
+
const tierMaxRows = resolveLegendMaxRows({ callerMaxRows: undefined, tier })
|
|
235
|
+
if (!fillBand) return { maxRows: tierMaxRows, legendWins: false }
|
|
236
|
+
return {
|
|
237
|
+
maxRows: legendEntryCap({
|
|
238
|
+
items,
|
|
239
|
+
containerW,
|
|
240
|
+
available,
|
|
241
|
+
...(tierMaxRows !== undefined && { defaultMaxRows: tierMaxRows }),
|
|
242
|
+
}),
|
|
243
|
+
legendWins: false,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
200
247
|
/**
|
|
201
248
|
* Resolve the plot rect from the measured box and the measured legend band. Pure and exported so
|
|
202
249
|
* the two floors below are testable without a `ResizeObserver`.
|
|
@@ -211,9 +258,11 @@ export function resolveLegendMaxRows(input: {
|
|
|
211
258
|
* `height={240}` toward zero and the body stopped rendering entirely. The plot stops at
|
|
212
259
|
* `VX.minPlotHeight` and the frame's own box grows by the difference instead — it is a flex
|
|
213
260
|
* column with `height: auto`, so that growth is automatic. Under `fill` the box CANNOT grow, so
|
|
214
|
-
* the legend is capped instead ({@link legendEntryCap})
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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. An unmeasured box (`resolvedHeight <= 0`, i.e. a
|
|
264
|
+
* `fill` frame before its first measurement) stays at 0 and renders nothing, exactly as before —
|
|
265
|
+
* the floor must never invent a height for a box nobody has measured.
|
|
217
266
|
*/
|
|
218
267
|
export function resolvePlotRect(input: {
|
|
219
268
|
containerW: number
|
|
@@ -221,12 +270,28 @@ export function resolvePlotRect(input: {
|
|
|
221
270
|
minWidth: number
|
|
222
271
|
sideLegendWidth: number
|
|
223
272
|
topBottomLegendHeight: number
|
|
273
|
+
/**
|
|
274
|
+
* The caller stated `legend.maxRows` on a `fill` frame, so the legend takes the rows it asked
|
|
275
|
+
* for and the plot takes what is left — `VX.minPlotHeight` stops being a floor. Without this the
|
|
276
|
+
* honoured cap would push the frame's content PAST its own cell (the plot holds 120px, the legend
|
|
277
|
+
* band adds its own) and paint over whatever sits below it; with it, the cost of the caller's
|
|
278
|
+
* number lands where they can see it. Spend the whole box on legend rows and there is no plot
|
|
279
|
+
* left to draw — that is their arithmetic, made visible rather than silently rolled up.
|
|
280
|
+
*/
|
|
281
|
+
legendWins?: boolean
|
|
224
282
|
}): { width: number; height: number } {
|
|
225
|
-
const {
|
|
283
|
+
const {
|
|
284
|
+
containerW,
|
|
285
|
+
resolvedHeight,
|
|
286
|
+
minWidth,
|
|
287
|
+
sideLegendWidth,
|
|
288
|
+
topBottomLegendHeight,
|
|
289
|
+
legendWins = false,
|
|
290
|
+
} = input
|
|
291
|
+
const plotFloor = legendWins ? 0 : VX.minPlotHeight
|
|
226
292
|
return {
|
|
227
293
|
width: containerW === 0 ? minWidth : Math.max(containerW - sideLegendWidth, 1),
|
|
228
|
-
height:
|
|
229
|
-
resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, VX.minPlotHeight),
|
|
294
|
+
height: resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, plotFloor),
|
|
230
295
|
}
|
|
231
296
|
}
|
|
232
297
|
|
|
@@ -272,17 +337,25 @@ function entriesWithinRows(items: readonly LegendEntry[], width: number, rows: n
|
|
|
272
337
|
* MEASURING the labels, not from assuming one entry per row — otherwise a five-entry legend that
|
|
273
338
|
* fits on one line would roll up to `+2 more` in every 240px cell, moving rendering for charts
|
|
274
339
|
* that never had the bug. Returns `undefined` when the whole legend already fits.
|
|
340
|
+
*
|
|
341
|
+
* This resolves an ABSENT cap. A caller who stated `legend.maxRows` never reaches here at all —
|
|
342
|
+
* `ChartFrame` routes around it — because `Math.min`-ing a stated number against this one made the
|
|
343
|
+
* documented escape inert in exactly the narrow `fill` panel it exists for
|
|
344
|
+
* ({@link resolveLegendMaxRows}).
|
|
275
345
|
*/
|
|
276
346
|
export function legendEntryCap(input: {
|
|
277
347
|
items: readonly LegendEntry[]
|
|
278
348
|
containerW: number
|
|
279
349
|
/** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
|
|
280
350
|
available: number
|
|
281
|
-
/**
|
|
282
|
-
|
|
351
|
+
/**
|
|
352
|
+
* The cap that applies when nothing explicit was stated — today only the tier's own default
|
|
353
|
+
* ({@link chartTierMetrics}). Two defaults contending: the smaller wins.
|
|
354
|
+
*/
|
|
355
|
+
defaultMaxRows?: number
|
|
283
356
|
}): number | undefined {
|
|
284
|
-
const { items, containerW, available,
|
|
285
|
-
if (containerW <= 0 || items.length === 0) return
|
|
357
|
+
const { items, containerW, available, defaultMaxRows } = input
|
|
358
|
+
if (containerW <= 0 || items.length === 0) return defaultMaxRows
|
|
286
359
|
const rows = Math.max(
|
|
287
360
|
1,
|
|
288
361
|
Math.floor((available - LEGEND_PAD_Y + VX.legendGap) / (LEGEND_LINE_H + VX.legendGap)),
|
|
@@ -291,8 +364,8 @@ export function legendEntryCap(input: {
|
|
|
291
364
|
legendBandHeight(rows) <= available &&
|
|
292
365
|
entriesWithinRows(items, containerW, rows) >= items.length
|
|
293
366
|
) {
|
|
294
|
-
return
|
|
367
|
+
return defaultMaxRows
|
|
295
368
|
}
|
|
296
369
|
const fitted = Math.max(1, entriesWithinRows(items, containerW, rows))
|
|
297
|
-
return
|
|
370
|
+
return defaultMaxRows === undefined ? fitted : Math.min(fitted, defaultMaxRows)
|
|
298
371
|
}
|