@fundar/data-chart-telling 0.0.45 → 0.0.47
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/dist/charts/BaseChart.svelte +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/facet/FacetLayout.svelte +32 -16
- package/dist/layout/facet/FacetLayout.svelte.d.ts +2 -1
- package/dist/plots/bar/Plot.svelte +14 -6
- package/dist/plots/line/Plot.svelte +24 -83
- package/dist/plots/line/valueLabelMargin.d.ts +27 -0
- package/dist/plots/line/valueLabelMargin.js +61 -0
- package/dist/plots/pyramid/Plot.svelte +18 -9
- package/dist/plots/scatter/Plot.svelte +12 -2
- package/dist/plots/utils/categoricalAxisMargin.d.ts +14 -0
- package/dist/plots/utils/categoricalAxisMargin.js +20 -0
- package/dist/plots/utils/labelOverflow.svelte.d.ts +22 -1
- package/dist/plots/utils/labelOverflow.svelte.js +78 -17
- package/dist/plots/utils/valueLabelMargin.d.ts +54 -0
- package/dist/plots/utils/valueLabelMargin.js +40 -0
- package/dist/types/charts/common.d.ts +1 -1
- package/dist/types/layout/facet.d.ts +20 -3
- package/package.json +1 -1
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
* The leaf snippet receives `{ data, width, height, facetValue?, selectedValue?,
|
|
24
24
|
* margins? }` and knows nothing about how that box or that data slice was
|
|
25
25
|
* produced, which is what lets any plot (line, bar, heatmap, …) drop in
|
|
26
|
-
* unchanged. `margins`
|
|
27
|
-
*
|
|
28
|
-
* shares the same content box.
|
|
26
|
+
* unchanged. `margins` only has a side set when `facet.marginsForFacet`
|
|
27
|
+
* reports it — forward it to the plot's own `margins` prop so every facet
|
|
28
|
+
* shares the same content box on that side.
|
|
29
29
|
*/
|
|
30
30
|
let {
|
|
31
31
|
width,
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
formatIndicator={facet.formatIndicator}
|
|
87
87
|
indicator={facet.indicator}
|
|
88
88
|
navButton={facet.navButton}
|
|
89
|
-
|
|
89
|
+
marginsForFacet={facet.marginsForFacet}
|
|
90
90
|
width={w}
|
|
91
91
|
height={h}
|
|
92
92
|
>
|
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export { THEMES } from './configuration/themes';
|
|
|
36
36
|
export type { ThemeName } from './configuration/themes';
|
|
37
37
|
export { groupBy, buildSeries } from './utils/grouping';
|
|
38
38
|
export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
|
|
39
|
+
export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
|
|
40
|
+
export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
|
|
39
41
|
export type { AxisValue, AxisScale, AxisBasedScalesConfig } from './types/layout/scales';
|
|
40
42
|
export type { Accessor, Series, SeriesProps, DataProps } from './types/plots/data/common';
|
|
41
43
|
export type { BasePlotProps, StylesConfig, MarkersConfig, SegmentsConfig, ScalesConfig, MarginConfig, TooltipConfig, HoverConfig, PointMatchingStrategy } from './types/plots/props';
|
package/dist/index.js
CHANGED
|
@@ -43,3 +43,5 @@ export { THEMES } from './configuration/themes';
|
|
|
43
43
|
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
44
44
|
export { groupBy, buildSeries } from './utils/grouping';
|
|
45
45
|
export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
|
|
46
|
+
export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
|
|
47
|
+
export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
import { isMobile } from '../../stores/device';
|
|
3
3
|
import { groupBy } from '../../utils/grouping';
|
|
4
4
|
import FacetIndicator from './FacetIndicator.svelte';
|
|
5
|
-
import { getConfiguration } from '../../configuration/config.svelte';
|
|
6
|
-
import { measureMaxLabelWidth } from '../plot/measureText';
|
|
7
5
|
import type {
|
|
8
6
|
FacetColumns,
|
|
9
7
|
FacetMode,
|
|
@@ -27,9 +25,13 @@
|
|
|
27
25
|
* out at once; `'paginated'` pages through the same grid in fixed chunks;
|
|
28
26
|
* `'carousel'` shows `visible` groups at a time with touch-drag/snap.
|
|
29
27
|
*
|
|
30
|
-
* `
|
|
31
|
-
* facet — pass it through `cell`'s own `margins` to a
|
|
32
|
-
* `margins` prop so they all share the same content box
|
|
28
|
+
* `marginsForFacet`, if given, sizes one shared margin (any/all sides)
|
|
29
|
+
* across every facet — pass it through `cell`'s own `margins` to a
|
|
30
|
+
* faceted plot's `margins` prop so they all share the same content box,
|
|
31
|
+
* instead of each facet growing its own margin independently off what its
|
|
32
|
+
* own content happens to need (which drifts out of alignment facet to
|
|
33
|
+
* facet, and — for a facet cramped enough that its content can never fully
|
|
34
|
+
* fit — can grow without bound).
|
|
33
35
|
*/
|
|
34
36
|
let {
|
|
35
37
|
data,
|
|
@@ -47,7 +49,7 @@
|
|
|
47
49
|
width,
|
|
48
50
|
height,
|
|
49
51
|
cell,
|
|
50
|
-
|
|
52
|
+
marginsForFacet,
|
|
51
53
|
}: {
|
|
52
54
|
data: TRow[];
|
|
53
55
|
by: Accessor<TRow, unknown>;
|
|
@@ -64,7 +66,7 @@
|
|
|
64
66
|
width: number;
|
|
65
67
|
height: number;
|
|
66
68
|
cell: FacetCellSnippet<TRow>;
|
|
67
|
-
|
|
69
|
+
marginsForFacet?: (rows: TRow[]) => MarginConfig;
|
|
68
70
|
} = $props();
|
|
69
71
|
|
|
70
72
|
function resolveResponsive<T>(value: Responsive<T>, mobile: boolean): T {
|
|
@@ -83,16 +85,30 @@
|
|
|
83
85
|
.map((v) => [v, groups.get(v)!] as [string, TRow[]]);
|
|
84
86
|
});
|
|
85
87
|
|
|
86
|
-
// One shared
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
// One shared margin, sized to whatever each facet's own `marginsForFacet`
|
|
89
|
+
// reports it naturally needs — componentwise `max` per side across every
|
|
90
|
+
// facet. `FacetLayout` itself measures nothing here (no font, no axis
|
|
91
|
+
// config, no plot-specific knowledge at all) — that's each plot type's
|
|
92
|
+
// own concern, expressed however it estimates its own margin (see e.g.
|
|
93
|
+
// `estimateLineValueLabelMargin`); this just compares already-resolved
|
|
94
|
+
// numbers. `undefined` when `marginsForFacet` isn't given; any side no
|
|
95
|
+
// facet reports (or reports as `'auto'`, which isn't comparable) stays
|
|
96
|
+
// unset, falling back to the plot's own default ('auto') sizing.
|
|
97
|
+
const MARGIN_SIDES = ['left', 'right', 'top', 'bottom'] as const;
|
|
91
98
|
const cellMargins = $derived.by((): MarginConfig | undefined => {
|
|
92
|
-
if (!
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
99
|
+
if (!marginsForFacet) return undefined;
|
|
100
|
+
const perFacet = entries.map(([, rows]) => marginsForFacet(rows));
|
|
101
|
+
const out: MarginConfig = {};
|
|
102
|
+
for (const side of MARGIN_SIDES) {
|
|
103
|
+
let max: number | undefined;
|
|
104
|
+
for (const margin of perFacet) {
|
|
105
|
+
const value = margin[side];
|
|
106
|
+
if (typeof value !== 'number') continue;
|
|
107
|
+
max = max === undefined ? value : Math.max(max, value);
|
|
108
|
+
}
|
|
109
|
+
if (max !== undefined) out[side] = max;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
96
112
|
});
|
|
97
113
|
|
|
98
114
|
const resolvedMode = $derived(resolveResponsive(mode, $isMobile));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FacetColumns, FacetMode, FacetNavLayout, Responsive, FacetCellSnippet, FacetIndicatorSnippet, FacetNavSnippet } from '../../types/layout/facet';
|
|
2
2
|
import type { Accessor } from '../../types/plots/data/common';
|
|
3
|
+
import type { MarginConfig } from '../../types/plots/props';
|
|
3
4
|
declare function $$render<TRow extends Record<string, unknown>>(): {
|
|
4
5
|
props: {
|
|
5
6
|
data: TRow[];
|
|
@@ -17,7 +18,7 @@ declare function $$render<TRow extends Record<string, unknown>>(): {
|
|
|
17
18
|
width: number;
|
|
18
19
|
height: number;
|
|
19
20
|
cell: FacetCellSnippet<TRow>;
|
|
20
|
-
|
|
21
|
+
marginsForFacet?: (rows: TRow[]) => MarginConfig;
|
|
21
22
|
};
|
|
22
23
|
exports: {};
|
|
23
24
|
bindings: "";
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
buildScopedMarkerGroups
|
|
12
12
|
} from '../utils/segments';
|
|
13
13
|
import { createBarLayout } from './layout.svelte';
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
createLabelMarginTracker,
|
|
16
|
+
halfDimensionOverflowCap
|
|
17
|
+
} from '../utils/labelOverflow.svelte';
|
|
18
|
+
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
16
19
|
import { buildHoverPoints } from './hoverPoints';
|
|
17
20
|
import { buildBarMarkers } from './buildBarMarkers';
|
|
18
21
|
import { resolveGapsForSeries, resolveDiscreteGapFills } from '../utils/gaps';
|
|
@@ -235,7 +238,7 @@
|
|
|
235
238
|
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
236
239
|
if (isHorizontal) {
|
|
237
240
|
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
238
|
-
|
|
241
|
+
const texts: string[] = [];
|
|
239
242
|
for (const s of resolvedSeries) {
|
|
240
243
|
const sy = resolveAccessor(s.y);
|
|
241
244
|
let extremeValue = 0;
|
|
@@ -249,14 +252,18 @@
|
|
|
249
252
|
}
|
|
250
253
|
}
|
|
251
254
|
if (extremeRow === undefined) continue;
|
|
252
|
-
|
|
255
|
+
texts.push(formatValue(extremeValue, s.name));
|
|
253
256
|
}
|
|
254
|
-
|
|
257
|
+
const right = estimateValueLabelMargin(texts, { canvasFont, reach: offset });
|
|
258
|
+
return { left: 0, right, top: 0, bottom: 0 };
|
|
255
259
|
}
|
|
256
260
|
const offset = Math.abs(typeof font?.dy === 'number' ? font.dy : 6);
|
|
257
261
|
return { left: 0, right: 0, top: Math.ceil(offset + fontSize * 1.2), bottom: 0 };
|
|
258
262
|
});
|
|
259
263
|
|
|
264
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
265
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
266
|
+
|
|
260
267
|
// A bar's value label can grow past the plot's own content box (e.g. a
|
|
261
268
|
// tall bar's `anchor: 'outside'` label pushing past the top margin) —
|
|
262
269
|
// `valueLabelFloor` covers the common case up front; each label's own
|
|
@@ -265,7 +272,8 @@
|
|
|
265
272
|
const labelMargin = createLabelMarginTracker(
|
|
266
273
|
() => margins,
|
|
267
274
|
() => resolvedSeries,
|
|
268
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
275
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
276
|
+
() => maxLabelOverflow
|
|
269
277
|
);
|
|
270
278
|
</script>
|
|
271
279
|
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
import { buildLineMarkers } from './buildLineMarkers';
|
|
9
9
|
import { buildGapMarkers } from './buildGapMarkers';
|
|
10
10
|
import { isGapValue } from '../utils/gaps';
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createLabelMarginTracker,
|
|
13
|
+
halfDimensionOverflowCap
|
|
14
|
+
} from '../utils/labelOverflow.svelte';
|
|
15
|
+
import { estimateLineValueLabelMargin } from './valueLabelMargin';
|
|
13
16
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
14
17
|
import ValueLabels from './ValueLabels.svelte';
|
|
15
18
|
import {
|
|
@@ -167,90 +170,27 @@
|
|
|
167
170
|
.filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
|
|
168
171
|
);
|
|
169
172
|
|
|
170
|
-
// Estimated overflow for a rightward-fanning end-of-line label
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// Only the fraction of the label that actually extends to the right of
|
|
180
|
-
// its own anchor point counts toward the margin — `textAnchor: 'start'`
|
|
181
|
-
// (the default `'start'`/`'outside'`-less case) puts the whole label
|
|
182
|
-
// there, but `'middle'` (`valAnchor: 'outside'`, or the shared-anchor
|
|
183
|
-
// default for anything other than `'start'`/`'end'`) centers it on the
|
|
184
|
-
// anchor, so only half sticks out; `'end'` puts none of it there at all.
|
|
185
|
-
// Same `preferredTA` derivation as `ValueLabels.svelte` (`'outside'`
|
|
186
|
-
// itself is a `dy`/`lineAnchor` shorthand, not a real textAnchor value —
|
|
187
|
-
// it resolves through `defaultTA` same as an unset one).
|
|
188
|
-
//
|
|
189
|
-
// The widest label is measured from `scales.y.domain` when the caller
|
|
190
|
-
// pins one (e.g. a shared domain covering a whole timeline range) rather
|
|
191
|
-
// than from `resolvedSeries`'s own values — a timeline scrubber's `data`
|
|
192
|
-
// is only the currently-revealed slice, so measuring straight off it
|
|
193
|
-
// would keep changing the floor (and, via `resetKey`, remounting the
|
|
194
|
-
// plot) on every tick even though the true worst case across the whole
|
|
195
|
-
// range never changes. Without a pinned domain there's no such stable
|
|
196
|
-
// source, so this falls back to the current frame's own values, same as
|
|
197
|
-
// no floor at all for a caller not pinning a domain in the first place.
|
|
198
|
-
//
|
|
199
|
-
// This estimate is close but not exact (declutter's real elbow/connector
|
|
200
|
-
// geometry vs. this reach formula) — with a pinned domain, that gap can
|
|
201
|
-
// still cost one extra DOM-measured correction (and its remount) on
|
|
202
|
-
// whichever tick first renders real declutter geometry, but never more
|
|
203
|
-
// than one across a whole scrub — see `LinePlot.ValueLabelStability`'s
|
|
204
|
-
// own test for what "settled" means here.
|
|
173
|
+
// Estimated overflow for a rightward-fanning end-of-line label — see
|
|
174
|
+
// `estimateLineValueLabelMargin`'s own doc comment for the covered case,
|
|
175
|
+
// the stability rationale (worst-case lane count, domain-pinned text), and
|
|
176
|
+
// its "close but not exact" caveat versus `ValueLabels.svelte`'s real
|
|
177
|
+
// declutter/connector geometry (see `LinePlot.ValueLabelStability`'s own
|
|
178
|
+
// test for what "settled" means here). Shared with a faceted caller's own
|
|
179
|
+
// `FacetConfig.marginsForFacet`, so a single plot and a facet-wide margin
|
|
180
|
+
// are always computed by the exact same code.
|
|
205
181
|
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
206
182
|
if (!showVals) return undefined;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const resolvedTextAnchor = font?.textAnchor === 'outside' ? undefined : font?.textAnchor;
|
|
214
|
-
const preferredTA = resolvedTextAnchor ?? defaultTA;
|
|
215
|
-
const rightFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
|
|
216
|
-
|
|
217
|
-
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
218
|
-
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
219
|
-
const lineCfg = cfg.line.valueLabels;
|
|
220
|
-
const seriesCount = resolvedSeries.length;
|
|
221
|
-
const reach =
|
|
222
|
-
seriesCount > 1
|
|
223
|
-
? lineCfg.elbowGap + (seriesCount - 1) * lineCfg.laneStep + lineCfg.labelGap
|
|
224
|
-
: Math.abs(baseDx);
|
|
225
|
-
|
|
226
|
-
const pinnedDomain = scales.y?.domain;
|
|
227
|
-
let maxLabelWidth = 0;
|
|
228
|
-
if (pinnedDomain && pinnedDomain.length >= 2) {
|
|
229
|
-
const lo = Number(pinnedDomain[0]);
|
|
230
|
-
const hi = Number(pinnedDomain[pinnedDomain.length - 1]);
|
|
231
|
-
for (const s of resolvedSeries) {
|
|
232
|
-
maxLabelWidth = Math.max(
|
|
233
|
-
maxLabelWidth,
|
|
234
|
-
measureTextWidth(formatValue(lo, s.name), canvasFont),
|
|
235
|
-
measureTextWidth(formatValue(hi, s.name), canvasFont)
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
} else {
|
|
239
|
-
for (const s of resolvedSeries) {
|
|
240
|
-
const sy = resolveAccessor(s.y);
|
|
241
|
-
for (const d of s.data) {
|
|
242
|
-
const v = Number(sy(d));
|
|
243
|
-
if (Number.isNaN(v)) continue;
|
|
244
|
-
maxLabelWidth = Math.max(maxLabelWidth, measureTextWidth(formatValue(v, s.name), canvasFont));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// +2px: canvas text measurement and svelteplot's own rendered SVG text
|
|
249
|
-
// can disagree by a pixel or so (different rendering engines) — a small
|
|
250
|
-
// buffer that narrows, but doesn't eliminate, the gap noted above.
|
|
251
|
-
return { left: 0, right: Math.ceil(reach + rightFraction * maxLabelWidth) + 2, top: 0, bottom: 0 };
|
|
183
|
+
return estimateLineValueLabelMargin(resolvedSeries, {
|
|
184
|
+
valAnchor,
|
|
185
|
+
fontStyle: styles.values?.fontStyle,
|
|
186
|
+
formatValue,
|
|
187
|
+
yDomain: scales.y?.domain
|
|
188
|
+
});
|
|
252
189
|
});
|
|
253
190
|
|
|
191
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
192
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
193
|
+
|
|
254
194
|
// Grows the plot's margin to fit value labels that overflow the content
|
|
255
195
|
// box. `valueLabelFloor` covers the common rightward case up front; each
|
|
256
196
|
// label's own rendered overflow still feeds `labelMargin.report` for
|
|
@@ -258,7 +198,8 @@
|
|
|
258
198
|
const labelMargin = createLabelMarginTracker(
|
|
259
199
|
() => margins,
|
|
260
200
|
() => resolvedSeries,
|
|
261
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
201
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
202
|
+
() => maxLabelOverflow
|
|
262
203
|
);
|
|
263
204
|
|
|
264
205
|
// Writes the resolved margin back into the bindable `margins` prop once it
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MarginOverflow } from '../utils/labelOverflow.svelte';
|
|
2
|
+
import type { Series } from '../../types/plots/data/common';
|
|
3
|
+
import type { FontStyle } from '../../types/plots/styling';
|
|
4
|
+
import type { ValueAnchor } from '../../types/plots/constants';
|
|
5
|
+
/**
|
|
6
|
+
* `LinePlot`'s own value-label margin estimate — stable across a timeline
|
|
7
|
+
* scrubber or a dataset change, since it's a fixed function of `series` and
|
|
8
|
+
* config, never DOM-measured (see `estimateValueLabelMargin`'s own doc
|
|
9
|
+
* comment). Used two ways: `LinePlot` itself calls this internally as its
|
|
10
|
+
* single-plot `valueLabelFloor`, and a faceted caller (e.g. `ChartPanel` in
|
|
11
|
+
* the consuming app) calls it per facet against that facet's own rows to
|
|
12
|
+
* build a shared `FacetConfig.marginsForFacet` — both get the exact same
|
|
13
|
+
* number for the exact same series, by construction, instead of the two call
|
|
14
|
+
* sites drifting apart with their own separate formulas.
|
|
15
|
+
*
|
|
16
|
+
* Only covers the common case: a rightward-fanning end-of-line label
|
|
17
|
+
* (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
|
|
18
|
+
* see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
|
|
19
|
+
* leftward fan isn't covered here and is left to the DOM-measured fallback.
|
|
20
|
+
*/
|
|
21
|
+
export declare function estimateLineValueLabelMargin<TData extends Record<string, unknown>>(series: Series<TData>[], options?: {
|
|
22
|
+
valAnchor?: ValueAnchor;
|
|
23
|
+
fontStyle?: FontStyle;
|
|
24
|
+
formatValue?: (value: number, name: string) => string;
|
|
25
|
+
/** A pinned y domain (e.g. `scales.y.domain`), covering the whole range a timeline scrubber might reveal — see this function's own stability note above. Falls back to `series`'s own current values when omitted. */
|
|
26
|
+
yDomain?: readonly (number | string | boolean | Date | null)[];
|
|
27
|
+
}): MarginOverflow | undefined;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolveAccessor } from '../utils/accessors';
|
|
2
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
3
|
+
import { estimateValueLabelMargin, laneReachUpperBound } from '../utils/valueLabelMargin';
|
|
4
|
+
/**
|
|
5
|
+
* `LinePlot`'s own value-label margin estimate — stable across a timeline
|
|
6
|
+
* scrubber or a dataset change, since it's a fixed function of `series` and
|
|
7
|
+
* config, never DOM-measured (see `estimateValueLabelMargin`'s own doc
|
|
8
|
+
* comment). Used two ways: `LinePlot` itself calls this internally as its
|
|
9
|
+
* single-plot `valueLabelFloor`, and a faceted caller (e.g. `ChartPanel` in
|
|
10
|
+
* the consuming app) calls it per facet against that facet's own rows to
|
|
11
|
+
* build a shared `FacetConfig.marginsForFacet` — both get the exact same
|
|
12
|
+
* number for the exact same series, by construction, instead of the two call
|
|
13
|
+
* sites drifting apart with their own separate formulas.
|
|
14
|
+
*
|
|
15
|
+
* Only covers the common case: a rightward-fanning end-of-line label
|
|
16
|
+
* (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
|
|
17
|
+
* see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
|
|
18
|
+
* leftward fan isn't covered here and is left to the DOM-measured fallback.
|
|
19
|
+
*/
|
|
20
|
+
export function estimateLineValueLabelMargin(series, options = {}) {
|
|
21
|
+
const cfg = getConfiguration();
|
|
22
|
+
const valAnchor = options.valAnchor ?? 'start';
|
|
23
|
+
const formatValue = options.formatValue ?? ((v, name) => `${name} - ${v}`);
|
|
24
|
+
const font = options.fontStyle;
|
|
25
|
+
const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0;
|
|
26
|
+
const baseDx = typeof font?.dx === 'number' ? font.dx : defaultDx;
|
|
27
|
+
if ((Math.sign(baseDx) || 1) !== 1)
|
|
28
|
+
return undefined;
|
|
29
|
+
const defaultTA = valAnchor === 'start' ? 'start' : valAnchor === 'end' ? 'end' : 'middle';
|
|
30
|
+
const resolvedTextAnchor = font?.textAnchor === 'outside' ? undefined : font?.textAnchor;
|
|
31
|
+
const preferredTA = resolvedTextAnchor ?? defaultTA;
|
|
32
|
+
const rightFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
|
|
33
|
+
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
34
|
+
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
35
|
+
const seriesCount = series.length;
|
|
36
|
+
const reach = seriesCount > 1 ? laneReachUpperBound(seriesCount, cfg.line.valueLabels) : Math.abs(baseDx);
|
|
37
|
+
const texts = [];
|
|
38
|
+
if (options.yDomain && options.yDomain.length >= 2) {
|
|
39
|
+
const lo = Number(options.yDomain[0]);
|
|
40
|
+
const hi = Number(options.yDomain[options.yDomain.length - 1]);
|
|
41
|
+
for (const s of series) {
|
|
42
|
+
texts.push(formatValue(lo, s.name), formatValue(hi, s.name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
for (const s of series) {
|
|
47
|
+
const sy = resolveAccessor(s.y);
|
|
48
|
+
for (const d of s.data) {
|
|
49
|
+
const v = Number(sy(d));
|
|
50
|
+
if (Number.isNaN(v))
|
|
51
|
+
continue;
|
|
52
|
+
texts.push(formatValue(v, s.name));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// +2px: canvas text measurement and svelteplot's own rendered SVG text
|
|
57
|
+
// can disagree by a pixel or so (different rendering engines) — a small
|
|
58
|
+
// buffer that narrows, but doesn't eliminate, the gap noted above.
|
|
59
|
+
const right = estimateValueLabelMargin(texts, { canvasFont, textFraction: rightFraction, reach }) + 2;
|
|
60
|
+
return { left: 0, right, top: 0, bottom: 0 };
|
|
61
|
+
}
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
7
7
|
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildPyramidBarMarkers } from './buildPyramidBarMarkers';
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
createLabelMarginTracker,
|
|
11
|
+
halfDimensionOverflowCap
|
|
12
|
+
} from '../utils/labelOverflow.svelte';
|
|
13
|
+
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
11
14
|
import {
|
|
12
15
|
buildGroupedSeries,
|
|
13
16
|
validateSegments,
|
|
@@ -277,8 +280,8 @@
|
|
|
277
280
|
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
278
281
|
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
const leftTexts: string[] = [];
|
|
284
|
+
const rightTexts: string[] = [];
|
|
282
285
|
for (const s of resolvedSeries) {
|
|
283
286
|
const sy = resolveAccessor(s.y);
|
|
284
287
|
let extreme = 0;
|
|
@@ -292,13 +295,18 @@
|
|
|
292
295
|
}
|
|
293
296
|
}
|
|
294
297
|
if (extremeRow === undefined) continue;
|
|
295
|
-
const
|
|
296
|
-
if (s.side === 'left')
|
|
297
|
-
else
|
|
298
|
+
const text = formatValue(extreme, s.name);
|
|
299
|
+
if (s.side === 'left') leftTexts.push(text);
|
|
300
|
+
else rightTexts.push(text);
|
|
298
301
|
}
|
|
299
|
-
|
|
302
|
+
const left = estimateValueLabelMargin(leftTexts, { canvasFont, reach: offset });
|
|
303
|
+
const right = estimateValueLabelMargin(rightTexts, { canvasFont, reach: offset });
|
|
304
|
+
return { left, right, top: 0, bottom: 0 };
|
|
300
305
|
});
|
|
301
306
|
|
|
307
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
308
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
309
|
+
|
|
302
310
|
// A pyramid bar's value label can grow past the plot's own content box —
|
|
303
311
|
// `valueLabelFloor` covers the common case up front; each label's own
|
|
304
312
|
// rendered overflow still feeds `labelMargin.report` for whatever that
|
|
@@ -306,7 +314,8 @@
|
|
|
306
314
|
const labelMargin = createLabelMarginTracker(
|
|
307
315
|
() => margins,
|
|
308
316
|
() => resolvedSeries,
|
|
309
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
317
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
318
|
+
() => maxLabelOverflow
|
|
310
319
|
);
|
|
311
320
|
</script>
|
|
312
321
|
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import { disabledDotsOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildScatterMarkers } from './buildScatterMarkers';
|
|
9
9
|
import { resolveRadiusPaddedDomain } from './resolveRadiusPaddedDomain';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createLabelMarginTracker,
|
|
12
|
+
halfDimensionOverflowCap
|
|
13
|
+
} from '../utils/labelOverflow.svelte';
|
|
11
14
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
12
15
|
import ValueLabels from './ValueLabels.svelte';
|
|
13
16
|
import {
|
|
@@ -214,6 +217,11 @@
|
|
|
214
217
|
};
|
|
215
218
|
});
|
|
216
219
|
|
|
220
|
+
// See `halfDimensionOverflowCap`'s own doc comment — relevant here mainly
|
|
221
|
+
// for a corner point with no room in any direction even after
|
|
222
|
+
// `resolveEdgeAwareAnchor`'s own flip.
|
|
223
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
224
|
+
|
|
217
225
|
// A scatter point's value label can grow past the plot's own content box
|
|
218
226
|
// (e.g. a point volcado toward a corner, past what its own edge-aware flip
|
|
219
227
|
// can fit) — reserving real space there is what each label's own overflow
|
|
@@ -223,7 +231,9 @@
|
|
|
223
231
|
// that frame is gone.
|
|
224
232
|
const labelMargin = createLabelMarginTracker(
|
|
225
233
|
() => margins,
|
|
226
|
-
() => resolvedSeries
|
|
234
|
+
() => resolvedSeries,
|
|
235
|
+
undefined,
|
|
236
|
+
() => maxLabelOverflow
|
|
227
237
|
);
|
|
228
238
|
</script>
|
|
229
239
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much margin an axis needs to fit the widest of `labels` in the
|
|
3
|
+
* theme's own axis font, plus its tick size/padding — any plot's own margin
|
|
4
|
+
* whenever what sits along that axis is plain tick-label text: `BarPlot`'s
|
|
5
|
+
* left margin when its main dimension draws as a category axis, or any
|
|
6
|
+
* plot's numeric axis whose tick-label width can vary a lot (e.g. `"5"` vs
|
|
7
|
+
* `"500,000"`). Off-DOM (canvas) and synchronous, so it's correct from the
|
|
8
|
+
* very first render and stable across a timeline scrubber or dataset
|
|
9
|
+
* change, the same way `estimateValueLabelMargin` is for value labels.
|
|
10
|
+
* Exported for a faceted caller (e.g. `ChartPanel` in the consuming app) to
|
|
11
|
+
* call per facet against that facet's own labels, building a shared
|
|
12
|
+
* `FacetConfig.marginsForFacet`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function estimateCategoricalAxisMargin(labels: string[]): number;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
2
|
+
import { measureMaxLabelWidth } from '../../layout/plot/measureText';
|
|
3
|
+
/**
|
|
4
|
+
* How much margin an axis needs to fit the widest of `labels` in the
|
|
5
|
+
* theme's own axis font, plus its tick size/padding — any plot's own margin
|
|
6
|
+
* whenever what sits along that axis is plain tick-label text: `BarPlot`'s
|
|
7
|
+
* left margin when its main dimension draws as a category axis, or any
|
|
8
|
+
* plot's numeric axis whose tick-label width can vary a lot (e.g. `"5"` vs
|
|
9
|
+
* `"500,000"`). Off-DOM (canvas) and synchronous, so it's correct from the
|
|
10
|
+
* very first render and stable across a timeline scrubber or dataset
|
|
11
|
+
* change, the same way `estimateValueLabelMargin` is for value labels.
|
|
12
|
+
* Exported for a faceted caller (e.g. `ChartPanel` in the consuming app) to
|
|
13
|
+
* call per facet against that facet's own labels, building a shared
|
|
14
|
+
* `FacetConfig.marginsForFacet`.
|
|
15
|
+
*/
|
|
16
|
+
export function estimateCategoricalAxisMargin(labels) {
|
|
17
|
+
const cfg = getConfiguration();
|
|
18
|
+
const axisFont = `${cfg.axis.fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
19
|
+
return (Math.ceil(measureMaxLabelWidth(labels, axisFont)) + cfg.axis.yTickSize + cfg.axis.yTickPadding);
|
|
20
|
+
}
|
|
@@ -6,6 +6,16 @@ export type MarginOverflow = {
|
|
|
6
6
|
top: number;
|
|
7
7
|
bottom: number;
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* A generic `maxOverflow` cap for {@link createLabelMarginTracker}: half of
|
|
11
|
+
* each dimension, so a facet cramped enough that its own value label can
|
|
12
|
+
* never fully fit still leaves the plot's own content box the other half,
|
|
13
|
+
* rather than `measured` growing without bound (see that function's own
|
|
14
|
+
* `maxOverflow` doc comment). The same backstop applies regardless of *what*
|
|
15
|
+
* overflows or *why* — every value-label-drawing plot (line/bar/pyramid/
|
|
16
|
+
* scatter) passes this, sized off its own `width`/`height`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function halfDimensionOverflowCap(width: number, height: number): MarginOverflow;
|
|
9
19
|
/**
|
|
10
20
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
11
21
|
* content box (`bounds`) and reports how far it overflows on each side.
|
|
@@ -66,7 +76,18 @@ export declare function createLabelMarginTracker(margins: () => MarginConfig | u
|
|
|
66
76
|
* every reset). DOM measurement can still grow past it for whatever this
|
|
67
77
|
* estimate doesn't cover.
|
|
68
78
|
*/
|
|
69
|
-
floor?: () => MarginOverflow
|
|
79
|
+
floor?: () => MarginOverflow,
|
|
80
|
+
/**
|
|
81
|
+
* Ceiling on how far `measured` can grow on each side, independent of
|
|
82
|
+
* `floor`. Without one, a cell too small to ever fully fit its own label
|
|
83
|
+
* (e.g. a narrow facet) keeps reporting overflow after every growth-
|
|
84
|
+
* triggered remount — since the label's absolute size never shrinks —
|
|
85
|
+
* and `measured` grows without bound, eventually leaving nothing for the
|
|
86
|
+
* plot's own content box. Capping it trades a permanently-clipped label
|
|
87
|
+
* in that case for a plot that still renders. Pass `Infinity` on a side
|
|
88
|
+
* (the default) to leave it uncapped.
|
|
89
|
+
*/
|
|
90
|
+
maxOverflow?: () => MarginOverflow): {
|
|
70
91
|
report: (overflow: MarginOverflow) => void;
|
|
71
92
|
readonly resolvedMargins: Partial<{
|
|
72
93
|
top: number | "auto";
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
2
2
|
const NO_OVERFLOW = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
3
|
+
/**
|
|
4
|
+
* A generic `maxOverflow` cap for {@link createLabelMarginTracker}: half of
|
|
5
|
+
* each dimension, so a facet cramped enough that its own value label can
|
|
6
|
+
* never fully fit still leaves the plot's own content box the other half,
|
|
7
|
+
* rather than `measured` growing without bound (see that function's own
|
|
8
|
+
* `maxOverflow` doc comment). The same backstop applies regardless of *what*
|
|
9
|
+
* overflows or *why* — every value-label-drawing plot (line/bar/pyramid/
|
|
10
|
+
* scatter) passes this, sized off its own `width`/`height`.
|
|
11
|
+
*/
|
|
12
|
+
export function halfDimensionOverflowCap(width, height) {
|
|
13
|
+
return {
|
|
14
|
+
left: Math.max(0, width * 0.5),
|
|
15
|
+
right: Math.max(0, width * 0.5),
|
|
16
|
+
top: Math.max(0, height * 0.5),
|
|
17
|
+
bottom: Math.max(0, height * 0.5)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
3
20
|
/**
|
|
4
21
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
5
22
|
* content box (`bounds`) and reports how far it overflows on each side.
|
|
@@ -114,7 +131,18 @@ export function createLabelMarginTracker(margins, resetKey,
|
|
|
114
131
|
* every reset). DOM measurement can still grow past it for whatever this
|
|
115
132
|
* estimate doesn't cover.
|
|
116
133
|
*/
|
|
117
|
-
floor
|
|
134
|
+
floor,
|
|
135
|
+
/**
|
|
136
|
+
* Ceiling on how far `measured` can grow on each side, independent of
|
|
137
|
+
* `floor`. Without one, a cell too small to ever fully fit its own label
|
|
138
|
+
* (e.g. a narrow facet) keeps reporting overflow after every growth-
|
|
139
|
+
* triggered remount — since the label's absolute size never shrinks —
|
|
140
|
+
* and `measured` grows without bound, eventually leaving nothing for the
|
|
141
|
+
* plot's own content box. Capping it trades a permanently-clipped label
|
|
142
|
+
* in that case for a plot that still renders. Pass `Infinity` on a side
|
|
143
|
+
* (the default) to leave it uncapped.
|
|
144
|
+
*/
|
|
145
|
+
maxOverflow) {
|
|
118
146
|
const floorValue = () => floor?.() ?? NO_OVERFLOW;
|
|
119
147
|
let measured = $state(floorValue());
|
|
120
148
|
// Skips the reset on the very first run (there's nothing to reset yet,
|
|
@@ -134,6 +162,17 @@ floor) {
|
|
|
134
162
|
measured = floorValue();
|
|
135
163
|
}
|
|
136
164
|
});
|
|
165
|
+
// Pure `Math.max` — no `maxOverflow` cap here. `measured` is a *tracked*
|
|
166
|
+
// value, not the applied margin, and must stay strictly monotonic
|
|
167
|
+
// regardless of anything else changing (see this function's own doc
|
|
168
|
+
// comment on why shrinking it back oscillates). `maxOverflow` is
|
|
169
|
+
// typically sized off the plot's own live `width`/`height` (see
|
|
170
|
+
// `halfDimensionOverflowCap`), which moves continuously during a resize
|
|
171
|
+
// — capping *here* would let a shrinking cap force `measured` down mid-
|
|
172
|
+
// resize, immediately re-exposing the overflow that grew it in the first
|
|
173
|
+
// place and re-triggering growth, over and over, fast enough to trip
|
|
174
|
+
// Svelte's `effect_update_depth_exceeded` guard. The cap is applied only
|
|
175
|
+
// where it can't feed back into `measured` itself — see `resolvedMargins`.
|
|
137
176
|
function report(overflow) {
|
|
138
177
|
const next = {
|
|
139
178
|
left: Math.max(measured.left, overflow.left),
|
|
@@ -156,33 +195,55 @@ floor) {
|
|
|
156
195
|
// number that only accounts for the value label. `AUTO_MARGIN_ESTIMATE` is
|
|
157
196
|
// a fixed constant, not `numericMargins`' own already-grown value, so this
|
|
158
197
|
// can't compound into unbounded growth across repeated reports.
|
|
198
|
+
//
|
|
199
|
+
// `maxOverflow`, if given, clamps `measured` down to a live cap *here*,
|
|
200
|
+
// read-only — every render re-reads the current cap and applies it fresh
|
|
201
|
+
// to the unclamped, monotonically-tracked `measured`, without ever
|
|
202
|
+
// writing the clamped value back into `measured` itself (see `report`'s
|
|
203
|
+
// own doc comment for why that distinction matters).
|
|
159
204
|
const resolvedMargins = $derived.by(() => {
|
|
160
205
|
const base = margins();
|
|
161
|
-
|
|
206
|
+
const cap = maxOverflow?.() ?? {
|
|
207
|
+
left: Infinity,
|
|
208
|
+
right: Infinity,
|
|
209
|
+
top: Infinity,
|
|
210
|
+
bottom: Infinity
|
|
211
|
+
};
|
|
212
|
+
const clamped = {
|
|
213
|
+
left: Math.min(measured.left, cap.left),
|
|
214
|
+
right: Math.min(measured.right, cap.right),
|
|
215
|
+
top: Math.min(measured.top, cap.top),
|
|
216
|
+
bottom: Math.min(measured.bottom, cap.bottom)
|
|
217
|
+
};
|
|
218
|
+
if (clamped.left <= 0 && clamped.right <= 0 && clamped.top <= 0 && clamped.bottom <= 0)
|
|
162
219
|
return base;
|
|
163
220
|
const out = { ...base };
|
|
164
|
-
if (
|
|
165
|
-
out.right = AUTO_MARGIN_ESTIMATE.right +
|
|
166
|
-
if (
|
|
167
|
-
out.left = AUTO_MARGIN_ESTIMATE.left +
|
|
168
|
-
if (
|
|
169
|
-
out.top = AUTO_MARGIN_ESTIMATE.top +
|
|
170
|
-
if (
|
|
171
|
-
out.bottom = AUTO_MARGIN_ESTIMATE.bottom +
|
|
221
|
+
if (clamped.right > 0 && base?.right === undefined)
|
|
222
|
+
out.right = AUTO_MARGIN_ESTIMATE.right + clamped.right;
|
|
223
|
+
if (clamped.left > 0 && base?.left === undefined)
|
|
224
|
+
out.left = AUTO_MARGIN_ESTIMATE.left + clamped.left;
|
|
225
|
+
if (clamped.top > 0 && base?.top === undefined)
|
|
226
|
+
out.top = AUTO_MARGIN_ESTIMATE.top + clamped.top;
|
|
227
|
+
if (clamped.bottom > 0 && base?.bottom === undefined)
|
|
228
|
+
out.bottom = AUTO_MARGIN_ESTIMATE.bottom + clamped.bottom;
|
|
172
229
|
return out;
|
|
173
230
|
});
|
|
174
231
|
// Bundles `resolvedMargins` with a remount key, ready to spread straight
|
|
175
232
|
// onto `<BasePlotLayout>` as its `margins`/`marginRemountKey` props (see
|
|
176
233
|
// that prop's doc comment for *why* a remount key is needed here at all)
|
|
177
|
-
// — keeps the caller from re-deriving the same key by hand.
|
|
178
|
-
// `
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// `
|
|
234
|
+
// — keeps the caller from re-deriving the same key by hand. Keyed off
|
|
235
|
+
// `resolvedMargins` itself (the actually-applied value) rather than raw
|
|
236
|
+
// `measured` — with a `maxOverflow` cap in play, `measured` can keep
|
|
237
|
+
// climbing past the point where it's clamped without the applied margin
|
|
238
|
+
// changing at all, and remounting for that would be pure waste. Also
|
|
239
|
+
// tracks the caller's own `margins()` — a caller that changes its
|
|
240
|
+
// explicit margins prop after mount (e.g. locking in a previously-
|
|
241
|
+
// measured value) needs that picked up just as cleanly as internal
|
|
242
|
+
// growth does; `resolvedMargins` alone already reflects that too, but
|
|
243
|
+
// spelling it out keeps the key's intent obvious.
|
|
183
244
|
const plotProps = $derived.by(() => ({
|
|
184
245
|
margins: resolvedMargins,
|
|
185
|
-
marginRemountKey: JSON.stringify({ base: margins(),
|
|
246
|
+
marginRemountKey: JSON.stringify({ base: margins(), resolvedMargins })
|
|
186
247
|
}));
|
|
187
248
|
return {
|
|
188
249
|
report,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one calculation every value-label-drawing plot (`LinePlot`, `BarPlot`,
|
|
3
|
+
* `PyramidPlot`, …) needs to reserve margin for its own value labels ahead of
|
|
4
|
+
* time, stable across a timeline scrubber or a dataset change: measure the
|
|
5
|
+
* widest candidate label (already off-DOM/canvas via `measureMaxLabelWidth`,
|
|
6
|
+
* so this is synchronous and exact, not an approximation needing a later
|
|
7
|
+
* DOM-measured correction), then add a fixed `reach` — whatever fixed gap
|
|
8
|
+
* sits between the anchor point and where the text itself starts, which
|
|
9
|
+
* differs per plot (a bar/pyramid's anchor offset; a line's elbow-routed
|
|
10
|
+
* lane geometry, see `laneReachUpperBound`).
|
|
11
|
+
*
|
|
12
|
+
* `texts` is the plot's own candidate list (its own concern — which values
|
|
13
|
+
* count as "the widest this could ever need to be", e.g. every row's value
|
|
14
|
+
* vs. just a pinned domain's extremes) — this function only measures and
|
|
15
|
+
* combines, it never decides which labels matter.
|
|
16
|
+
*/
|
|
17
|
+
export declare function estimateValueLabelMargin(texts: string[], options: {
|
|
18
|
+
/** Canvas font shorthand, e.g. `"12px sans-serif"`, matching the labels' own rendered font. */
|
|
19
|
+
canvasFont: string;
|
|
20
|
+
/**
|
|
21
|
+
* Fraction of the widest label's own width that extends toward the
|
|
22
|
+
* margin being reserved — `1` (the default) for a label anchored so
|
|
23
|
+
* its whole width reaches outward (`textAnchor: 'start'`), `0.5` for
|
|
24
|
+
* one centered on its anchor (`'middle'`), `0` for one anchored back
|
|
25
|
+
* over the plot's own content (no text-width contribution, though
|
|
26
|
+
* `reach` can still apply).
|
|
27
|
+
*/
|
|
28
|
+
textFraction?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Fixed gap (px) between the anchor point and the start of the label
|
|
31
|
+
* text — always reserved, even when `texts` is empty, matching every
|
|
32
|
+
* caller's existing behaviour of never collapsing to zero margin
|
|
33
|
+
* outright (a plot with no value-label-bearing data yet still keeps
|
|
34
|
+
* whatever fixed offset its style config asks for).
|
|
35
|
+
*/
|
|
36
|
+
reach?: number;
|
|
37
|
+
}): number;
|
|
38
|
+
/**
|
|
39
|
+
* Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
|
|
40
|
+
* into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
|
|
41
|
+
* colliding labels never uses more lanes than it has members, and a run can
|
|
42
|
+
* never have more members than there are series total — so `elbowGap +
|
|
43
|
+
* (seriesCount - 1) * laneStep + labelGap` is always >= the real, rendered
|
|
44
|
+
* reach, for *any* arrangement `declutter1D`/`laneAssignment` could ever
|
|
45
|
+
* produce. That's what makes it safe to use as a stable floor: it doesn't
|
|
46
|
+
* depend on which series happen to collide on the current frame (which can
|
|
47
|
+
* change tick to tick as a timeline scrubs), only on how many series exist —
|
|
48
|
+
* so the margin it implies never needs to change across a scrub either.
|
|
49
|
+
*/
|
|
50
|
+
export declare function laneReachUpperBound(seriesCount: number, cfg: {
|
|
51
|
+
elbowGap: number;
|
|
52
|
+
laneStep: number;
|
|
53
|
+
labelGap: number;
|
|
54
|
+
}): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { measureMaxLabelWidth } from '../../layout/plot/measureText';
|
|
2
|
+
/**
|
|
3
|
+
* The one calculation every value-label-drawing plot (`LinePlot`, `BarPlot`,
|
|
4
|
+
* `PyramidPlot`, …) needs to reserve margin for its own value labels ahead of
|
|
5
|
+
* time, stable across a timeline scrubber or a dataset change: measure the
|
|
6
|
+
* widest candidate label (already off-DOM/canvas via `measureMaxLabelWidth`,
|
|
7
|
+
* so this is synchronous and exact, not an approximation needing a later
|
|
8
|
+
* DOM-measured correction), then add a fixed `reach` — whatever fixed gap
|
|
9
|
+
* sits between the anchor point and where the text itself starts, which
|
|
10
|
+
* differs per plot (a bar/pyramid's anchor offset; a line's elbow-routed
|
|
11
|
+
* lane geometry, see `laneReachUpperBound`).
|
|
12
|
+
*
|
|
13
|
+
* `texts` is the plot's own candidate list (its own concern — which values
|
|
14
|
+
* count as "the widest this could ever need to be", e.g. every row's value
|
|
15
|
+
* vs. just a pinned domain's extremes) — this function only measures and
|
|
16
|
+
* combines, it never decides which labels matter.
|
|
17
|
+
*/
|
|
18
|
+
export function estimateValueLabelMargin(texts, options) {
|
|
19
|
+
const maxWidth = measureMaxLabelWidth(texts, options.canvasFont);
|
|
20
|
+
const textFraction = options.textFraction ?? 1;
|
|
21
|
+
const reach = options.reach ?? 0;
|
|
22
|
+
return Math.ceil(reach + textFraction * maxWidth);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
|
|
26
|
+
* into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
|
|
27
|
+
* colliding labels never uses more lanes than it has members, and a run can
|
|
28
|
+
* never have more members than there are series total — so `elbowGap +
|
|
29
|
+
* (seriesCount - 1) * laneStep + labelGap` is always >= the real, rendered
|
|
30
|
+
* reach, for *any* arrangement `declutter1D`/`laneAssignment` could ever
|
|
31
|
+
* produce. That's what makes it safe to use as a stable floor: it doesn't
|
|
32
|
+
* depend on which series happen to collide on the current frame (which can
|
|
33
|
+
* change tick to tick as a timeline scrubs), only on how many series exist —
|
|
34
|
+
* so the margin it implies never needs to change across a scrub either.
|
|
35
|
+
*/
|
|
36
|
+
export function laneReachUpperBound(seriesCount, cfg) {
|
|
37
|
+
if (seriesCount <= 1)
|
|
38
|
+
return 0;
|
|
39
|
+
return cfg.elbowGap + (seriesCount - 1) * cfg.laneStep + cfg.labelGap;
|
|
40
|
+
}
|
|
@@ -14,7 +14,7 @@ export type ChartPlotContext<TRow extends Record<string, unknown>> = {
|
|
|
14
14
|
facetValue?: string;
|
|
15
15
|
selectedValue?: TimeValue;
|
|
16
16
|
legend?: LegendPlotContext;
|
|
17
|
-
/** Present only when faceted with `facet.
|
|
17
|
+
/** Present only when faceted with `facet.marginsForFacet` — forward to the plot's own `margins` prop so every facet shares the same content box. */
|
|
18
18
|
margins?: MarginConfig;
|
|
19
19
|
};
|
|
20
20
|
export type ChartPlotSnippet<TRow extends Record<string, unknown>> = Snippet<[
|
|
@@ -13,7 +13,7 @@ export type FacetCellContext<TRow extends Record<string, unknown>> = {
|
|
|
13
13
|
data: TRow[];
|
|
14
14
|
width: number;
|
|
15
15
|
height: number;
|
|
16
|
-
/**
|
|
16
|
+
/** Margin sized off every facet's own natural need, per side — see `marginsForFacet`. Present only when it's given. */
|
|
17
17
|
margins?: MarginConfig;
|
|
18
18
|
};
|
|
19
19
|
export type FacetCellSnippet<TRow extends Record<string, unknown>> = Snippet<[
|
|
@@ -43,6 +43,23 @@ export type FacetConfig<TRow extends Record<string, unknown>> = {
|
|
|
43
43
|
formatIndicator?: (current: number, total: number) => string;
|
|
44
44
|
indicator?: FacetIndicatorSnippet;
|
|
45
45
|
navButton?: FacetNavSnippet;
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* One facet's own natural margin need, per side — measured across every
|
|
48
|
+
* facet and combined with `Math.max` per side (`FacetLayout`'s own
|
|
49
|
+
* `cellMargins`) to size one shared margin every facet's `cell` receives
|
|
50
|
+
* through its own `margins`, instead of each facet growing its own margin
|
|
51
|
+
* independently off what its own content happens to need (which drifts
|
|
52
|
+
* out of alignment facet to facet, and — for a facet cramped enough that
|
|
53
|
+
* its content can never fully fit — can grow without bound). A plot type
|
|
54
|
+
* that draws content needing this (a categorical axis's labels, a value
|
|
55
|
+
* label's own text) implements this by calling its own margin-estimating
|
|
56
|
+
* export against the facet's rows — e.g. `BarPlot`'s left margin from its
|
|
57
|
+
* category labels, `LinePlot`'s right margin from its end-of-line value
|
|
58
|
+
* labels (see each plot's own docs for what its estimator assumes, such as
|
|
59
|
+
* `LinePlot`'s one-line-per-facet caveat) — this callback only carries
|
|
60
|
+
* that per-facet result up to where every facet's results can be compared.
|
|
61
|
+
* Any side left unset by every facet stays unpinned, falling back to the
|
|
62
|
+
* plot's own default (`'auto'`) sizing.
|
|
63
|
+
*/
|
|
64
|
+
marginsForFacet?: (rows: TRow[]) => MarginConfig;
|
|
48
65
|
};
|