@fundar/data-chart-telling 0.0.39 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plots/bar/Plot.svelte +275 -249
- package/dist/plots/line/Plot.svelte +227 -182
- package/dist/plots/line/Plot.svelte.d.ts +2 -2
- package/dist/plots/pyramid/Plot.svelte +288 -260
- package/dist/plots/scatter/Plot.svelte +266 -242
- package/dist/plots/utils/labelOverflow.svelte.d.ts +17 -1
- package/dist/plots/utils/labelOverflow.svelte.js +44 -8
- package/package.json +1 -1
|
@@ -1,247 +1,271 @@
|
|
|
1
|
-
<script
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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),
|
|
@@ -127,15 +160,18 @@ export function createLabelMarginTracker(margins) {
|
|
|
127
160
|
out.bottom = AUTO_MARGIN_ESTIMATE.bottom + measured.bottom;
|
|
128
161
|
return out;
|
|
129
162
|
});
|
|
130
|
-
// Bundles `resolvedMargins` with a remount key
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
163
|
+
// Bundles `resolvedMargins` with a remount key, ready to spread straight
|
|
164
|
+
// onto `<BasePlotLayout>` as its `margins`/`marginRemountKey` props (see
|
|
165
|
+
// that prop's doc comment for *why* a remount key is needed here at all)
|
|
166
|
+
// — keeps the caller from re-deriving the same key by hand. Tracks both
|
|
167
|
+
// `measured` (this tracker's own auto-grown overflow) and the caller's
|
|
168
|
+
// own `margins()` — a caller that changes its explicit margins prop
|
|
169
|
+
// after mount (e.g. locking in a previously-measured value) needs that
|
|
170
|
+
// picked up just as cleanly as internal growth does; keying only on
|
|
171
|
+
// `measured` would silently miss it.
|
|
136
172
|
const plotProps = $derived.by(() => ({
|
|
137
173
|
margins: resolvedMargins,
|
|
138
|
-
marginRemountKey: JSON.stringify(measured)
|
|
174
|
+
marginRemountKey: JSON.stringify({ base: margins(), measured })
|
|
139
175
|
}));
|
|
140
176
|
return {
|
|
141
177
|
report,
|