@fundar/data-chart-telling 0.0.45 → 0.0.46
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 +5 -4
- package/dist/plots/line/Plot.svelte +28 -81
- package/dist/plots/line/valueLabelMargin.d.ts +27 -0
- package/dist/plots/line/valueLabelMargin.js +61 -0
- package/dist/plots/pyramid/Plot.svelte +9 -7
- 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 +12 -1
- package/dist/plots/utils/labelOverflow.svelte.js +22 -5
- 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: "";
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
} from '../utils/segments';
|
|
13
13
|
import { createBarLayout } from './layout.svelte';
|
|
14
14
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
15
|
-
import {
|
|
15
|
+
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
16
16
|
import { buildHoverPoints } from './hoverPoints';
|
|
17
17
|
import { buildBarMarkers } from './buildBarMarkers';
|
|
18
18
|
import { resolveGapsForSeries, resolveDiscreteGapFills } from '../utils/gaps';
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
236
236
|
if (isHorizontal) {
|
|
237
237
|
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
238
|
-
|
|
238
|
+
const texts: string[] = [];
|
|
239
239
|
for (const s of resolvedSeries) {
|
|
240
240
|
const sy = resolveAccessor(s.y);
|
|
241
241
|
let extremeValue = 0;
|
|
@@ -249,9 +249,10 @@
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
if (extremeRow === undefined) continue;
|
|
252
|
-
|
|
252
|
+
texts.push(formatValue(extremeValue, s.name));
|
|
253
253
|
}
|
|
254
|
-
|
|
254
|
+
const right = estimateValueLabelMargin(texts, { canvasFont, reach: offset });
|
|
255
|
+
return { left: 0, right, top: 0, bottom: 0 };
|
|
255
256
|
}
|
|
256
257
|
const offset = Math.abs(typeof font?.dy === 'number' ? font.dy : 6);
|
|
257
258
|
return { left: 0, right: 0, top: Math.ceil(offset + fontSize * 1.2), bottom: 0 };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { buildGapMarkers } from './buildGapMarkers';
|
|
10
10
|
import { isGapValue } from '../utils/gaps';
|
|
11
11
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
12
|
-
import {
|
|
12
|
+
import { estimateLineValueLabelMargin } from './valueLabelMargin';
|
|
13
13
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
14
14
|
import ValueLabels from './ValueLabels.svelte';
|
|
15
15
|
import {
|
|
@@ -167,88 +167,34 @@
|
|
|
167
167
|
.filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
|
|
168
168
|
);
|
|
169
169
|
|
|
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.
|
|
170
|
+
// Estimated overflow for a rightward-fanning end-of-line label — see
|
|
171
|
+
// `estimateLineValueLabelMargin`'s own doc comment for the covered case,
|
|
172
|
+
// the stability rationale (worst-case lane count, domain-pinned text), and
|
|
173
|
+
// its "close but not exact" caveat versus `ValueLabels.svelte`'s real
|
|
174
|
+
// declutter/connector geometry (see `LinePlot.ValueLabelStability`'s own
|
|
175
|
+
// test for what "settled" means here). Shared with a faceted caller's own
|
|
176
|
+
// `FacetConfig.marginsForFacet`, so a single plot and a facet-wide margin
|
|
177
|
+
// are always computed by the exact same code.
|
|
205
178
|
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
206
179
|
if (!showVals) return undefined;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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);
|
|
180
|
+
return estimateLineValueLabelMargin(resolvedSeries, {
|
|
181
|
+
valAnchor,
|
|
182
|
+
fontStyle: styles.values?.fontStyle,
|
|
183
|
+
formatValue,
|
|
184
|
+
yDomain: scales.y?.domain
|
|
185
|
+
});
|
|
186
|
+
});
|
|
225
187
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 };
|
|
188
|
+
// A facet cramped enough that its own end-of-line label can never fully
|
|
189
|
+
// fit would otherwise grow `measured` without bound (see
|
|
190
|
+
// `createLabelMarginTracker`'s own doc comment) — capped at half of each
|
|
191
|
+
// dimension, so the plot's own content box never shrinks past the other
|
|
192
|
+
// half no matter how little room a label has to work with.
|
|
193
|
+
const maxLabelOverflow = $derived({
|
|
194
|
+
left: Math.max(0, width * 0.5),
|
|
195
|
+
right: Math.max(0, width * 0.5),
|
|
196
|
+
top: Math.max(0, height * 0.5),
|
|
197
|
+
bottom: Math.max(0, height * 0.5)
|
|
252
198
|
});
|
|
253
199
|
|
|
254
200
|
// Grows the plot's margin to fit value labels that overflow the content
|
|
@@ -258,7 +204,8 @@
|
|
|
258
204
|
const labelMargin = createLabelMarginTracker(
|
|
259
205
|
() => margins,
|
|
260
206
|
() => resolvedSeries,
|
|
261
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
207
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
208
|
+
() => maxLabelOverflow
|
|
262
209
|
);
|
|
263
210
|
|
|
264
211
|
// 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
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildPyramidBarMarkers } from './buildPyramidBarMarkers';
|
|
9
9
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
10
|
-
import {
|
|
10
|
+
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
11
11
|
import {
|
|
12
12
|
buildGroupedSeries,
|
|
13
13
|
validateSegments,
|
|
@@ -277,8 +277,8 @@
|
|
|
277
277
|
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
278
278
|
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
const leftTexts: string[] = [];
|
|
281
|
+
const rightTexts: string[] = [];
|
|
282
282
|
for (const s of resolvedSeries) {
|
|
283
283
|
const sy = resolveAccessor(s.y);
|
|
284
284
|
let extreme = 0;
|
|
@@ -292,11 +292,13 @@
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
if (extremeRow === undefined) continue;
|
|
295
|
-
const
|
|
296
|
-
if (s.side === 'left')
|
|
297
|
-
else
|
|
295
|
+
const text = formatValue(extreme, s.name);
|
|
296
|
+
if (s.side === 'left') leftTexts.push(text);
|
|
297
|
+
else rightTexts.push(text);
|
|
298
298
|
}
|
|
299
|
-
|
|
299
|
+
const left = estimateValueLabelMargin(leftTexts, { canvasFont, reach: offset });
|
|
300
|
+
const right = estimateValueLabelMargin(rightTexts, { canvasFont, reach: offset });
|
|
301
|
+
return { left, right, top: 0, bottom: 0 };
|
|
300
302
|
});
|
|
301
303
|
|
|
302
304
|
// A pyramid bar's value label can grow past the plot's own content box —
|
|
@@ -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
|
+
}
|
|
@@ -66,7 +66,18 @@ export declare function createLabelMarginTracker(margins: () => MarginConfig | u
|
|
|
66
66
|
* every reset). DOM measurement can still grow past it for whatever this
|
|
67
67
|
* estimate doesn't cover.
|
|
68
68
|
*/
|
|
69
|
-
floor?: () => MarginOverflow
|
|
69
|
+
floor?: () => MarginOverflow,
|
|
70
|
+
/**
|
|
71
|
+
* Ceiling on how far `measured` can grow on each side, independent of
|
|
72
|
+
* `floor`. Without one, a cell too small to ever fully fit its own label
|
|
73
|
+
* (e.g. a narrow facet) keeps reporting overflow after every growth-
|
|
74
|
+
* triggered remount — since the label's absolute size never shrinks —
|
|
75
|
+
* and `measured` grows without bound, eventually leaving nothing for the
|
|
76
|
+
* plot's own content box. Capping it trades a permanently-clipped label
|
|
77
|
+
* in that case for a plot that still renders. Pass `Infinity` on a side
|
|
78
|
+
* (the default) to leave it uncapped.
|
|
79
|
+
*/
|
|
80
|
+
maxOverflow?: () => MarginOverflow): {
|
|
70
81
|
report: (overflow: MarginOverflow) => void;
|
|
71
82
|
readonly resolvedMargins: Partial<{
|
|
72
83
|
top: number | "auto";
|
|
@@ -114,7 +114,18 @@ export function createLabelMarginTracker(margins, resetKey,
|
|
|
114
114
|
* every reset). DOM measurement can still grow past it for whatever this
|
|
115
115
|
* estimate doesn't cover.
|
|
116
116
|
*/
|
|
117
|
-
floor
|
|
117
|
+
floor,
|
|
118
|
+
/**
|
|
119
|
+
* Ceiling on how far `measured` can grow on each side, independent of
|
|
120
|
+
* `floor`. Without one, a cell too small to ever fully fit its own label
|
|
121
|
+
* (e.g. a narrow facet) keeps reporting overflow after every growth-
|
|
122
|
+
* triggered remount — since the label's absolute size never shrinks —
|
|
123
|
+
* and `measured` grows without bound, eventually leaving nothing for the
|
|
124
|
+
* plot's own content box. Capping it trades a permanently-clipped label
|
|
125
|
+
* in that case for a plot that still renders. Pass `Infinity` on a side
|
|
126
|
+
* (the default) to leave it uncapped.
|
|
127
|
+
*/
|
|
128
|
+
maxOverflow) {
|
|
118
129
|
const floorValue = () => floor?.() ?? NO_OVERFLOW;
|
|
119
130
|
let measured = $state(floorValue());
|
|
120
131
|
// Skips the reset on the very first run (there's nothing to reset yet,
|
|
@@ -135,11 +146,17 @@ floor) {
|
|
|
135
146
|
}
|
|
136
147
|
});
|
|
137
148
|
function report(overflow) {
|
|
149
|
+
const cap = maxOverflow?.() ?? {
|
|
150
|
+
left: Infinity,
|
|
151
|
+
right: Infinity,
|
|
152
|
+
top: Infinity,
|
|
153
|
+
bottom: Infinity
|
|
154
|
+
};
|
|
138
155
|
const next = {
|
|
139
|
-
left: Math.max(measured.left, overflow.left),
|
|
140
|
-
right: Math.max(measured.right, overflow.right),
|
|
141
|
-
top: Math.max(measured.top, overflow.top),
|
|
142
|
-
bottom: Math.max(measured.bottom, overflow.bottom)
|
|
156
|
+
left: Math.min(cap.left, Math.max(measured.left, overflow.left)),
|
|
157
|
+
right: Math.min(cap.right, Math.max(measured.right, overflow.right)),
|
|
158
|
+
top: Math.min(cap.top, Math.max(measured.top, overflow.top)),
|
|
159
|
+
bottom: Math.min(cap.bottom, Math.max(measured.bottom, overflow.bottom))
|
|
143
160
|
};
|
|
144
161
|
if (next.left !== measured.left ||
|
|
145
162
|
next.right !== measured.right ||
|
|
@@ -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
|
};
|