@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.
@@ -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` is only present when `facet.labelsForMargin` is
27
- * given — forward it to the plot's own `margins` prop so every facet
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
- labelsForMargin={facet.labelsForMargin}
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
- * `labelsForMargin`, if given, sizes one shared left margin across every
31
- * facet — pass it through `cell`'s own `margins` to a faceted plot's
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
- labelsForMargin,
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
- labelsForMargin?: (rows: TRow[]) => string[];
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 left margin, sized to the widest label across every facet —
87
- // measured off-DOM (canvas), so it's correct from the very first render
88
- // with no mount/remount needed. `undefined` when `labelsForMargin` isn't given.
89
- const cfg = $derived(getConfiguration());
90
- const axisFont = $derived(`${cfg.axis.fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`);
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 (!labelsForMargin) return undefined;
93
- const labels = entries.flatMap(([, rows]) => labelsForMargin(rows));
94
- const left = Math.ceil(measureMaxLabelWidth(labels, axisFont)) + cfg.axis.yTickSize + cfg.axis.yTickPadding;
95
- return { left };
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
- labelsForMargin?: (rows: TRow[]) => string[];
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 { measureTextWidth } from '../../layout/plot/measureText';
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
- let widestLabel = 0;
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
- widestLabel = Math.max(widestLabel, measureTextWidth(formatValue(extremeValue, s.name), canvasFont));
252
+ texts.push(formatValue(extremeValue, s.name));
253
253
  }
254
- return { left: 0, right: Math.ceil(offset + widestLabel), top: 0, bottom: 0 };
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 { measureTextWidth } from '../../layout/plot/measureText';
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 (the
171
- // default: `valAnchor: 'start'`, or any anchor whose resolved `dx` isn't
172
- // negative). `lanesUsed` (see `declutter1D`/`laneAssignment`) never
173
- // exceeds the series count, so that bounds the reach even without
174
- // knowing which points will actually end up clustered together. A
175
- // leftward fan (negative `dx`) instead flips itself to the right when it
176
- // runs out of room see `ValueLabels.svelte`'s own doc comment — so it
177
- // carries no such risk and is left to the DOM-measured fallback below.
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
- const font = styles.values?.fontStyle;
208
- const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0;
209
- const baseDx = typeof font?.dx === 'number' ? font.dx : defaultDx;
210
- if ((Math.sign(baseDx) || 1) !== 1) return undefined;
211
-
212
- const defaultTA = valAnchor === 'start' ? 'start' : valAnchor === 'end' ? 'end' : 'middle';
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);
180
+ return estimateLineValueLabelMargin(resolvedSeries, {
181
+ valAnchor,
182
+ fontStyle: styles.values?.fontStyle,
183
+ formatValue,
184
+ yDomain: scales.y?.domain
185
+ });
186
+ });
225
187
 
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 };
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 { measureTextWidth } from '../../layout/plot/measureText';
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
- let leftWidth = 0;
281
- let rightWidth = 0;
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 labelWidth = measureTextWidth(formatValue(extreme, s.name), canvasFont);
296
- if (s.side === 'left') leftWidth = Math.max(leftWidth, labelWidth);
297
- else rightWidth = Math.max(rightWidth, labelWidth);
295
+ const text = formatValue(extreme, s.name);
296
+ if (s.side === 'left') leftTexts.push(text);
297
+ else rightTexts.push(text);
298
298
  }
299
- return { left: Math.ceil(offset + leftWidth), right: Math.ceil(offset + rightWidth), top: 0, bottom: 0 };
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.labelsForMargin` — forward to the plot's own `margins` prop so every facet shares the same content box. */
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
- /** Left margin sized to the widest label across every facet — see `labelsForMargin`. Present only when it's given. */
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
- /** Candidate label strings for one facet's own rows — measured across every facet to size a shared left margin (`cell`'s own `margins`). */
47
- labelsForMargin?: (rows: TRow[]) => string[];
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.45",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"