@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.
@@ -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: "";
@@ -11,8 +11,11 @@
11
11
  buildScopedMarkerGroups
12
12
  } from '../utils/segments';
13
13
  import { createBarLayout } from './layout.svelte';
14
- import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
15
- import { measureTextWidth } from '../../layout/plot/measureText';
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
- let widestLabel = 0;
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
- widestLabel = Math.max(widestLabel, measureTextWidth(formatValue(extremeValue, s.name), canvasFont));
255
+ texts.push(formatValue(extremeValue, s.name));
253
256
  }
254
- return { left: 0, right: Math.ceil(offset + widestLabel), top: 0, bottom: 0 };
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 { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
12
- import { measureTextWidth } from '../../layout/plot/measureText';
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 (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.
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
- 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);
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 { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
10
- import { measureTextWidth } from '../../layout/plot/measureText';
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
- let leftWidth = 0;
281
- let rightWidth = 0;
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 labelWidth = measureTextWidth(formatValue(extreme, s.name), canvasFont);
296
- if (s.side === 'left') leftWidth = Math.max(leftWidth, labelWidth);
297
- else rightWidth = Math.max(rightWidth, labelWidth);
298
+ const text = formatValue(extreme, s.name);
299
+ if (s.side === 'left') leftTexts.push(text);
300
+ else rightTexts.push(text);
298
301
  }
299
- return { left: Math.ceil(offset + leftWidth), right: Math.ceil(offset + rightWidth), top: 0, bottom: 0 };
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 { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
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
- if (measured.left <= 0 && measured.right <= 0 && measured.top <= 0 && measured.bottom <= 0)
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 (measured.right > 0 && base?.right === undefined)
165
- out.right = AUTO_MARGIN_ESTIMATE.right + measured.right;
166
- if (measured.left > 0 && base?.left === undefined)
167
- out.left = AUTO_MARGIN_ESTIMATE.left + measured.left;
168
- if (measured.top > 0 && base?.top === undefined)
169
- out.top = AUTO_MARGIN_ESTIMATE.top + measured.top;
170
- if (measured.bottom > 0 && base?.bottom === undefined)
171
- out.bottom = AUTO_MARGIN_ESTIMATE.bottom + measured.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. Tracks both
178
- // `measured` (this tracker's own auto-grown overflow) and the caller's
179
- // own `margins()` — a caller that changes its explicit margins prop
180
- // after mount (e.g. locking in a previously-measured value) needs that
181
- // picked up just as cleanly as internal growth does; keying only on
182
- // `measured` would silently miss it.
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(), measured })
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.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.47",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"