@fundar/data-chart-telling 0.0.39 → 0.0.40

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.
@@ -1,247 +1,271 @@
1
- <script
2
- lang="ts"
3
- generics="TData extends Record<string, unknown>"
4
- >
5
- import { resolveAccessor } from '../utils/accessors';
6
- import { getConfiguration } from '../../configuration/config.svelte';
7
- import { paletteColor } from '../../utils/color';
8
- import { buildSeries } from '../../utils/grouping';
9
- import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
10
- import { disabledDotsOverride } from '../utils/legendDisabled';
11
- import { buildScatterMarkers } from './buildScatterMarkers';
12
- import { resolveRadiusPaddedDomain } from './resolveRadiusPaddedDomain';
13
- import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
14
- import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
15
- import ValueLabels from './ValueLabels.svelte';
16
- import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
17
- import { buildGroupedSeries, validateSegments, buildScopedMarkerGroups } from '../utils/segments';
18
- import { numericMargin } from '../../layout/plot/margins';
19
- import type { BasePlotProps } from '../../types/plots/props';
20
- import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
21
- import type { Series } from '../../types/plots/data/common';
22
- import type { ScatterDataProps } from '../../types/plots/data/scatter';
23
- import type { AxisBasedStylesConfig } from '../../types/layout/styles';
24
- import type { ScatterSegmentStyle } from '../../types/plots/segments/common';
25
- import type { AxisBasedMarkersConfig } from '../../types/markers/common';
26
- import type { LegendDisabledStyle } from '../../types/layout/legend';
27
-
28
- /**
29
- * A point cloud on continuous x/y axes. Accepts either a pre-built `series`
30
- * array or flat `data/x/y/z` props — mirrors the line/bar plot contract so
31
- * the chart-level container/timeline/facet/legend wrap it unchanged. `r`
32
- * gives every dot a per-row radius (bubble sizing), independent of `z`
33
- * grouping/coloring — see {@link ScatterDataProps} for its auto-scaling
34
- * behavior.
35
- */
36
- type Props = BasePlotProps<
37
- ScatterDataProps<TData>,
38
- TData,
39
- AxisBasedStylesConfig,
40
- ScatterSegmentStyle,
41
- AxisBasedMarkersConfig,
42
- AxisBasedScalesConfig<TData>
43
- >;
44
-
45
- let {
46
- width,
47
- height,
48
- series,
49
- data,
50
- x,
51
- y,
52
- z,
53
- r,
54
- styles = {},
55
- segments = {},
56
- markers = [],
57
- scales = {},
58
- margins,
59
- tooltip = undefined,
60
- }: Props = $props();
61
-
62
- const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
63
- const legendInteraction = $derived(getLegendInteraction());
64
-
65
- $effect(() => { validateSegments(segments); });
66
-
67
- // A scatter point cloud is discrete (no connecting line), so adjacent
68
- // segments never need a bridge point at their boundary — see
69
- // buildGroupedSeries's own doc.
70
- const groupedSeries = $derived(
71
- buildGroupedSeries<Series<TData>, ScatterSegmentStyle>(resolvedSeries, segments, false),
72
- );
73
-
74
- const cfg = $derived(getConfiguration());
75
- const showVals = $derived(styles.values?.show ?? false);
76
- const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
77
- const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
78
-
79
- // A value label hovered while `styles.values.hoverIsolate` is on dims
80
- // every other series exactly like a legend toggle would — local to this
81
- // one `<Plot>` instance (not the shared HoverStore) so the gesture can't
82
- // leak into unrelated tooltip/crosshair behavior.
83
- let hoverIsolatedSeries = $state<string | null>(null);
84
-
85
- function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
86
- return (
87
- legendInteraction?.disabledSeries.get(name) ??
88
- (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
89
- ? { opacity: cfg.legend.disabledOpacity }
90
- : undefined)
91
- );
92
- }
93
-
94
- const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
95
- const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
96
- const flat = $derived(resolvedSeries.flatMap((s) => s.data));
97
-
98
- // A legend-disabled series is dimmed on the mark itself, but never surfaces
99
- // in hover/tooltip — its points are simply absent from the candidate list.
100
- const hoverPoints = $derived(
101
- resolvedSeries.flatMap((s, idx) => {
102
- if (legendInteraction?.disabledSeries.has(s.name)) return [];
103
- const sx = resolveAccessor(s.x);
104
- const sy = resolveAccessor(s.y);
105
- return s.data.map((d) => ({
106
- x: sx(d) as AxisValue,
107
- y: Number(sy(d)),
108
- row: d,
109
- label: `${sx(d)}, ${sy(d)}`,
110
- series: s.name,
111
- color: paletteColor(idx, cfg.palette),
112
- }));
113
- }),
114
- );
115
-
116
- // Points scatter freely across x/y rather than sharing discrete x values —
117
- // 'punctual' matches the single nearest point, not a whole column.
118
- const hover = $derived({
119
- active: tooltip != null || hasHoverMarker(markers),
120
- points: hoverPoints,
121
- label: (row: TData) => `${xAcc(row)}, ${yAcc(row)}`,
122
- strategy: resolveHoverStrategy(tooltip?.strategy, markers, 'punctual'),
123
- sync: resolveHoverSync(tooltip?.sync, markers),
124
- tooltip,
125
- });
126
-
127
- const scatterMarkers = $derived(
128
- buildScatterMarkers({
129
- groupedSeries,
130
- r,
131
- disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name)),
132
- }),
133
- );
134
-
135
- const scopedGroups = $derived(
136
- buildScopedMarkerGroups<TData, Series<TData>, ScatterSegmentStyle>({
137
- groupedSeries,
138
- segments,
139
- colorFor: (_series, seriesIndex) => paletteColor(seriesIndex, cfg.palette),
140
- segmentColorFor: (style, defaultColor) => style?.dotFill ?? defaultColor,
141
- }),
142
- );
143
-
144
- // svelteplot treats a per-row `r` (a field key or accessor function) as a
145
- // proper channel bound to its own auto sqrt scale — the raw values it
146
- // returns (e.g. a population in the millions) are data, not pixels, and
147
- // get compressed into a modest, roughly data-magnitude-independent pixel
148
- // range regardless of how large the raw numbers are. A literal constant
149
- // `r` (or the theme/segment `dotRadius` fallback), by contrast, *is*
150
- // already a literal pixel value. `AUTO_SCALED_RADIUS_ESTIMATE` stands in
151
- // for whatever svelteplot's own r-scale ends up rendering at, since the
152
- // real value isn't knowable until that scale is built.
153
- const AUTO_SCALED_RADIUS_ESTIMATE = 20;
154
-
155
- // The largest radius any dot actually renders at pads the scale domain
156
- // below so a point at the data extreme doesn't get its own circle clipped
157
- // by the frame. svelteplot's own 'auto' margin only measures axis chrome,
158
- // never mark geometry.
159
- const maxDotRadius = $derived.by(() => {
160
- let max = 0;
161
- for (const marker of scatterMarkers) {
162
- const rConst = typeof marker.r === 'number' ? marker.r : undefined;
163
- const isChannel = typeof marker.r === 'function';
164
- const fallbackR = marker.style?.dotRadius ?? cfg.scatter.dotRadius;
165
- if (rConst !== undefined) {
166
- max = Math.max(max, rConst);
167
- } else if (isChannel) {
168
- max = Math.max(max, AUTO_SCALED_RADIUS_ESTIMATE);
169
- } else {
170
- max = Math.max(max, fallbackR);
171
- }
172
- }
173
- return max;
174
- });
175
-
176
- // Best-effort pre-render estimate of the plot's own inner content size —
177
- // the real size isn't known until svelteplot measures its own axis
178
- // chrome, so this is only used to convert a pixel radius into a domain
179
- // padding amount, not as the actual rendered margin.
180
- const estimatedMargins = $derived({ ...cfg.margins, ...margins });
181
- const estimatedInnerWidth = $derived(
182
- width - numericMargin('left', estimatedMargins.left) - numericMargin('right', estimatedMargins.right),
183
- );
184
- const estimatedInnerHeight = $derived(
185
- height - numericMargin('top', estimatedMargins.top) - numericMargin('bottom', estimatedMargins.bottom),
186
- );
187
-
188
- const resolvedScales = $derived.by((): AxisBasedScalesConfig<TData> => {
189
- if (maxDotRadius <= 0) return scales;
190
- const xValues = flat.map((d) => Number(xAcc(d)));
191
- const yValues = flat.map((d) => Number(yAcc(d)));
192
- const paddedX = scales.x?.domain
193
- ? undefined
194
- : resolveRadiusPaddedDomain(xValues, maxDotRadius, estimatedInnerWidth);
195
- const paddedY = scales.y?.domain
196
- ? undefined
197
- : resolveRadiusPaddedDomain(yValues, maxDotRadius, estimatedInnerHeight);
198
- if (!paddedX && !paddedY) return scales;
199
- return {
200
- ...scales,
201
- x: paddedX ? { ...scales.x, domain: paddedX } : scales.x,
202
- y: paddedY ? { ...scales.y, domain: paddedY } : scales.y,
203
- };
204
- });
205
-
206
- // A scatter point's value label can grow past the plot's own content box
207
- // (e.g. a point volcado toward a corner, past what its own edge-aware flip
208
- // can fit) — reserving real space there is what each label's own overflow
209
- // measurement feeds into here, via a tracker local to this one `<Plot>`
210
- // instance. See `labelOverflow.svelte.ts`.
211
- const labelMargin = createLabelMarginTracker(() => margins);
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { resolveAccessor } from '../utils/accessors';
3
+ import { getConfiguration } from '../../configuration/config.svelte';
4
+ import { paletteColor } from '../../utils/color';
5
+ import { buildSeries } from '../../utils/grouping';
6
+ import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
7
+ import { disabledDotsOverride } from '../utils/legendDisabled';
8
+ import { buildScatterMarkers } from './buildScatterMarkers';
9
+ import { resolveRadiusPaddedDomain } from './resolveRadiusPaddedDomain';
10
+ import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
11
+ import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
12
+ import ValueLabels from './ValueLabels.svelte';
13
+ import {
14
+ hasHoverMarker,
15
+ resolveHoverStrategy,
16
+ resolveHoverSync
17
+ } from '../../layout/tooltip/utils';
18
+ import {
19
+ buildGroupedSeries,
20
+ validateSegments,
21
+ buildScopedMarkerGroups
22
+ } from '../utils/segments';
23
+ import { numericMargin } from '../../layout/plot/margins';
24
+ import type { BasePlotProps } from '../../types/plots/props';
25
+ import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
26
+ import type { Series } from '../../types/plots/data/common';
27
+ import type { ScatterDataProps } from '../../types/plots/data/scatter';
28
+ import type { AxisBasedStylesConfig } from '../../types/layout/styles';
29
+ import type { ScatterSegmentStyle } from '../../types/plots/segments/common';
30
+ import type { AxisBasedMarkersConfig } from '../../types/markers/common';
31
+ import type { LegendDisabledStyle } from '../../types/layout/legend';
32
+
33
+ /**
34
+ * A point cloud on continuous x/y axes. Accepts either a pre-built `series`
35
+ * array or flat `data/x/y/z` props — mirrors the line/bar plot contract so
36
+ * the chart-level container/timeline/facet/legend wrap it unchanged. `r`
37
+ * gives every dot a per-row radius (bubble sizing), independent of `z`
38
+ * grouping/coloring — see {@link ScatterDataProps} for its auto-scaling
39
+ * behavior.
40
+ */
41
+ type Props = BasePlotProps<
42
+ ScatterDataProps<TData>,
43
+ TData,
44
+ AxisBasedStylesConfig,
45
+ ScatterSegmentStyle,
46
+ AxisBasedMarkersConfig,
47
+ AxisBasedScalesConfig<TData>
48
+ >;
49
+
50
+ let {
51
+ width,
52
+ height,
53
+ series,
54
+ data,
55
+ x,
56
+ y,
57
+ z,
58
+ r,
59
+ styles = {},
60
+ segments = {},
61
+ markers = [],
62
+ scales = {},
63
+ margins,
64
+ tooltip = undefined
65
+ }: Props = $props();
66
+
67
+ const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
68
+ const legendInteraction = $derived(getLegendInteraction());
69
+
70
+ $effect(() => {
71
+ validateSegments(segments);
72
+ });
73
+
74
+ // A scatter point cloud is discrete (no connecting line), so adjacent
75
+ // segments never need a bridge point at their boundary — see
76
+ // buildGroupedSeries's own doc.
77
+ const groupedSeries = $derived(
78
+ buildGroupedSeries<Series<TData>, ScatterSegmentStyle>(resolvedSeries, segments, false)
79
+ );
80
+
81
+ const cfg = $derived(getConfiguration());
82
+ const showVals = $derived(styles.values?.show ?? false);
83
+ const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
84
+ const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
85
+
86
+ // A value label hovered while `styles.values.hoverIsolate` is on dims
87
+ // every other series exactly like a legend toggle would — local to this
88
+ // one `<Plot>` instance (not the shared HoverStore) so the gesture can't
89
+ // leak into unrelated tooltip/crosshair behavior.
90
+ let hoverIsolatedSeries = $state<string | null>(null);
91
+
92
+ function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
93
+ return (
94
+ legendInteraction?.disabledSeries.get(name) ??
95
+ (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
96
+ ? { opacity: cfg.legend.disabledOpacity }
97
+ : undefined)
98
+ );
99
+ }
100
+
101
+ const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
102
+ const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
103
+ const flat = $derived(resolvedSeries.flatMap((s) => s.data));
104
+
105
+ // A legend-disabled series is dimmed on the mark itself, but never surfaces
106
+ // in hover/tooltip — its points are simply absent from the candidate list.
107
+ const hoverPoints = $derived(
108
+ resolvedSeries.flatMap((s, idx) => {
109
+ if (legendInteraction?.disabledSeries.has(s.name)) return [];
110
+ const sx = resolveAccessor(s.x);
111
+ const sy = resolveAccessor(s.y);
112
+ return s.data.map((d) => ({
113
+ x: sx(d) as AxisValue,
114
+ y: Number(sy(d)),
115
+ row: d,
116
+ label: `${sx(d)}, ${sy(d)}`,
117
+ series: s.name,
118
+ color: paletteColor(idx, cfg.palette)
119
+ }));
120
+ })
121
+ );
122
+
123
+ // Points scatter freely across x/y rather than sharing discrete x values —
124
+ // 'punctual' matches the single nearest point, not a whole column.
125
+ const hover = $derived({
126
+ active: tooltip != null || hasHoverMarker(markers),
127
+ points: hoverPoints,
128
+ label: (row: TData) => `${xAcc(row)}, ${yAcc(row)}`,
129
+ strategy: resolveHoverStrategy(tooltip?.strategy, markers, 'punctual'),
130
+ sync: resolveHoverSync(tooltip?.sync, markers),
131
+ tooltip
132
+ });
133
+
134
+ const scatterMarkers = $derived(
135
+ buildScatterMarkers({
136
+ groupedSeries,
137
+ r,
138
+ disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name))
139
+ })
140
+ );
141
+
142
+ const scopedGroups = $derived(
143
+ buildScopedMarkerGroups<TData, Series<TData>, ScatterSegmentStyle>({
144
+ groupedSeries,
145
+ segments,
146
+ colorFor: (_series, seriesIndex) => paletteColor(seriesIndex, cfg.palette),
147
+ segmentColorFor: (style, defaultColor) => style?.dotFill ?? defaultColor
148
+ })
149
+ );
150
+
151
+ // svelteplot treats a per-row `r` (a field key or accessor function) as a
152
+ // proper channel bound to its own auto sqrt scale the raw values it
153
+ // returns (e.g. a population in the millions) are data, not pixels, and
154
+ // get compressed into a modest, roughly data-magnitude-independent pixel
155
+ // range regardless of how large the raw numbers are. A literal constant
156
+ // `r` (or the theme/segment `dotRadius` fallback), by contrast, *is*
157
+ // already a literal pixel value. `AUTO_SCALED_RADIUS_ESTIMATE` stands in
158
+ // for whatever svelteplot's own r-scale ends up rendering at, since the
159
+ // real value isn't knowable until that scale is built.
160
+ const AUTO_SCALED_RADIUS_ESTIMATE = 20;
161
+
162
+ // The largest radius any dot actually renders at pads the scale domain
163
+ // below so a point at the data extreme doesn't get its own circle clipped
164
+ // by the frame. svelteplot's own 'auto' margin only measures axis chrome,
165
+ // never mark geometry.
166
+ const maxDotRadius = $derived.by(() => {
167
+ let max = 0;
168
+ for (const marker of scatterMarkers) {
169
+ const rConst = typeof marker.r === 'number' ? marker.r : undefined;
170
+ const isChannel = typeof marker.r === 'function';
171
+ const fallbackR = marker.style?.dotRadius ?? cfg.scatter.dotRadius;
172
+ if (rConst !== undefined) {
173
+ max = Math.max(max, rConst);
174
+ } else if (isChannel) {
175
+ max = Math.max(max, AUTO_SCALED_RADIUS_ESTIMATE);
176
+ } else {
177
+ max = Math.max(max, fallbackR);
178
+ }
179
+ }
180
+ return max;
181
+ });
182
+
183
+ // Best-effort pre-render estimate of the plot's own inner content size —
184
+ // the real size isn't known until svelteplot measures its own axis
185
+ // chrome, so this is only used to convert a pixel radius into a domain
186
+ // padding amount, not as the actual rendered margin.
187
+ const estimatedMargins = $derived({ ...cfg.margins, ...margins });
188
+ const estimatedInnerWidth = $derived(
189
+ width -
190
+ numericMargin('left', estimatedMargins.left) -
191
+ numericMargin('right', estimatedMargins.right)
192
+ );
193
+ const estimatedInnerHeight = $derived(
194
+ height -
195
+ numericMargin('top', estimatedMargins.top) -
196
+ numericMargin('bottom', estimatedMargins.bottom)
197
+ );
198
+
199
+ const resolvedScales = $derived.by((): AxisBasedScalesConfig<TData> => {
200
+ if (maxDotRadius <= 0) return scales;
201
+ const xValues = flat.map((d) => Number(xAcc(d)));
202
+ const yValues = flat.map((d) => Number(yAcc(d)));
203
+ const paddedX = scales.x?.domain
204
+ ? undefined
205
+ : resolveRadiusPaddedDomain(xValues, maxDotRadius, estimatedInnerWidth);
206
+ const paddedY = scales.y?.domain
207
+ ? undefined
208
+ : resolveRadiusPaddedDomain(yValues, maxDotRadius, estimatedInnerHeight);
209
+ if (!paddedX && !paddedY) return scales;
210
+ return {
211
+ ...scales,
212
+ x: paddedX ? { ...scales.x, domain: paddedX } : scales.x,
213
+ y: paddedY ? { ...scales.y, domain: paddedY } : scales.y
214
+ };
215
+ });
216
+
217
+ // A scatter point's value label can grow past the plot's own content box
218
+ // (e.g. a point volcado toward a corner, past what its own edge-aware flip
219
+ // can fit) — reserving real space there is what each label's own overflow
220
+ // measurement feeds into here, via a tracker local to this one `<Plot>`
221
+ // instance. See `labelOverflow.svelte.ts`. Resets on `resolvedSeries` so
222
+ // overflow from an earlier, differently-shaped frame doesn't linger once
223
+ // that frame is gone.
224
+ const labelMargin = createLabelMarginTracker(
225
+ () => margins,
226
+ () => resolvedSeries
227
+ );
212
228
  </script>
213
229
 
214
230
  <BasePlotLayout
215
- {width}
216
- {height}
217
- {styles}
218
- data={flat}
219
- getX={xAcc}
220
- getY={(d) => Number(yAcc(d))}
221
- {hover}
222
- scales={resolvedScales}
223
- {...labelMargin.plotProps}
224
- markers={[...scatterMarkers, ...markers]}
225
- seriesData={resolvedSeries}
226
- {scopedGroups}
231
+ {width}
232
+ {height}
233
+ {styles}
234
+ data={flat}
235
+ getX={xAcc}
236
+ getY={(d) => Number(yAcc(d))}
237
+ {hover}
238
+ scales={resolvedScales}
239
+ {...labelMargin.plotProps}
240
+ markers={[...scatterMarkers, ...markers]}
241
+ seriesData={resolvedSeries}
242
+ {scopedGroups}
227
243
  >
228
- {#snippet children({ numericMargins })}
229
- {#if showVals}
230
- {#each groupedSeries as group (group.series.name)}
231
- <ValueLabels
232
- {group}
233
- {formatValue}
234
- values={styles.values}
235
- defaultColor={cfg.axis.color}
236
- disabled={effectiveDisabledFor(group.series.name)}
237
- {width}
238
- {height}
239
- {numericMargins}
240
- onHoverSeries={hoverIsolate ? () => { hoverIsolatedSeries = group.series.name; } : undefined}
241
- onHoverEnd={hoverIsolate ? () => { hoverIsolatedSeries = null; } : undefined}
242
- onOverflow={labelMargin.report}
243
- />
244
- {/each}
245
- {/if}
246
- {/snippet}
244
+ {#snippet children({ numericMargins })}
245
+ {#if showVals}
246
+ {#each groupedSeries as group (group.series.name)}
247
+ <ValueLabels
248
+ {group}
249
+ {formatValue}
250
+ values={styles.values}
251
+ defaultColor={cfg.axis.color}
252
+ disabled={effectiveDisabledFor(group.series.name)}
253
+ {width}
254
+ {height}
255
+ {numericMargins}
256
+ onHoverSeries={hoverIsolate
257
+ ? () => {
258
+ hoverIsolatedSeries = group.series.name;
259
+ }
260
+ : undefined}
261
+ onHoverEnd={hoverIsolate
262
+ ? () => {
263
+ hoverIsolatedSeries = null;
264
+ }
265
+ : undefined}
266
+ onOverflow={labelMargin.report}
267
+ />
268
+ {/each}
269
+ {/if}
270
+ {/snippet}
247
271
  </BasePlotLayout>
@@ -39,8 +39,24 @@ export declare function watchLabelOverflow(args: {
39
39
  * "shrink margin → overflow reappears → grow margin → …". Each side is only
40
40
  * ever set when the caller's own `margins` prop hasn't already pinned it, so
41
41
  * an explicit caller override always wins over this auto-grown one.
42
+ *
43
+ * That monotonic growth is only sound for a *stable* dataset — it assumes
44
+ * every reading describes the same thing being measured, just possibly at an
45
+ * earlier, not-yet-settled frame. It stops being sound once the caller's own
46
+ * data changes identity underneath it (e.g. a timeline scrubber revealing a
47
+ * different slice of rows on every tick): an overflow measured against
48
+ * *that* frame's rendered labels doesn't describe the next frame's, so
49
+ * carrying it forward forever can leave a plot permanently — and wrongly —
50
+ * margin-inflated by whatever the single most cluttered frame it ever
51
+ * passed through needed, long after scrubbing away from it. `resetKey`, if
52
+ * given, is compared by identity on every call; a change drops `measured`
53
+ * back to zero so the next frame's overflow is measured fresh against only
54
+ * what's actually on screen now. Pass whatever reactive value identifies
55
+ * "what's currently being plotted" for the caller (e.g. its own resolved
56
+ * series list) — a plain reference/value comparison, not a deep one, so it
57
+ * only need change identity when the rendered content actually does.
42
58
  */
43
- export declare function createLabelMarginTracker(margins: () => MarginConfig | undefined): {
59
+ export declare function createLabelMarginTracker(margins: () => MarginConfig | undefined, resetKey?: () => unknown): {
44
60
  report: (overflow: MarginOverflow) => void;
45
61
  readonly resolvedMargins: Partial<{
46
62
  top: number | "auto";
@@ -87,9 +87,42 @@ export function watchLabelOverflow(args) {
87
87
  * "shrink margin → overflow reappears → grow margin → …". Each side is only
88
88
  * ever set when the caller's own `margins` prop hasn't already pinned it, so
89
89
  * an explicit caller override always wins over this auto-grown one.
90
+ *
91
+ * That monotonic growth is only sound for a *stable* dataset — it assumes
92
+ * every reading describes the same thing being measured, just possibly at an
93
+ * earlier, not-yet-settled frame. It stops being sound once the caller's own
94
+ * data changes identity underneath it (e.g. a timeline scrubber revealing a
95
+ * different slice of rows on every tick): an overflow measured against
96
+ * *that* frame's rendered labels doesn't describe the next frame's, so
97
+ * carrying it forward forever can leave a plot permanently — and wrongly —
98
+ * margin-inflated by whatever the single most cluttered frame it ever
99
+ * passed through needed, long after scrubbing away from it. `resetKey`, if
100
+ * given, is compared by identity on every call; a change drops `measured`
101
+ * back to zero so the next frame's overflow is measured fresh against only
102
+ * what's actually on screen now. Pass whatever reactive value identifies
103
+ * "what's currently being plotted" for the caller (e.g. its own resolved
104
+ * series list) — a plain reference/value comparison, not a deep one, so it
105
+ * only need change identity when the rendered content actually does.
90
106
  */
91
- export function createLabelMarginTracker(margins) {
107
+ export function createLabelMarginTracker(margins, resetKey) {
92
108
  let measured = $state(NO_OVERFLOW);
109
+ // Skips the reset on the very first run (there's nothing to reset yet,
110
+ // and `measured` already starts at `NO_OVERFLOW`) so only a later,
111
+ // genuine change to `resetKey` clears accumulated growth.
112
+ let resetKeyInitialized = false;
113
+ let lastResetKey;
114
+ $effect(() => {
115
+ const key = resetKey?.();
116
+ if (!resetKeyInitialized) {
117
+ resetKeyInitialized = true;
118
+ lastResetKey = key;
119
+ return;
120
+ }
121
+ if (key !== lastResetKey) {
122
+ lastResetKey = key;
123
+ measured = NO_OVERFLOW;
124
+ }
125
+ });
93
126
  function report(overflow) {
94
127
  const next = {
95
128
  left: Math.max(measured.left, overflow.left),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"