@fundar/data-chart-telling 0.0.36 → 0.0.38
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/index.d.ts +1 -0
- package/dist/layout/coordinates/CameraFit.svelte +111 -0
- package/dist/layout/coordinates/CameraFit.svelte.d.ts +15 -0
- package/dist/layout/coordinates/CoordinatesLayout.svelte +169 -95
- package/dist/layout/coordinates/CoordinatesLayout.svelte.d.ts +1 -1
- package/dist/layout/coordinates/camera.svelte.d.ts +17 -0
- package/dist/layout/coordinates/camera.svelte.js +106 -0
- package/dist/layout/coordinates/resolveCameraTarget.d.ts +14 -0
- package/dist/layout/coordinates/resolveCameraTarget.js +19 -0
- package/dist/layout/coordinates/zoom.svelte.d.ts +11 -15
- package/dist/layout/coordinates/zoom.svelte.js +15 -12
- package/dist/layout/plot/BasePlotLayout.svelte +312 -282
- package/dist/plots/line/Plot.svelte +6 -18
- package/dist/plots/line/ValueLabels.svelte +103 -83
- package/dist/plots/line/ValueLabels.svelte.d.ts +1 -1
- package/dist/plots/utils/declutter.js +7 -4
- package/dist/plots/utils/labelOverflow.svelte.js +13 -4
- package/dist/types/layout/coordinates.d.ts +11 -5
- package/dist/types/plots/camera.d.ts +42 -0
- package/dist/types/plots/camera.js +1 -0
- package/package.json +1 -1
|
@@ -49,9 +49,8 @@
|
|
|
49
49
|
tooltip = undefined,
|
|
50
50
|
}: Props = $props();
|
|
51
51
|
|
|
52
|
-
// Fills
|
|
53
|
-
//
|
|
54
|
-
// `segments` — see `GapsAwarePlotStylesConfig` (`types/layout/styles.ts`).
|
|
52
|
+
// Fills missing-data gaps with an interpolated, distinctly-styled bridge,
|
|
53
|
+
// resolved per series.
|
|
55
54
|
const gaps = $derived(styles.gaps ?? {});
|
|
56
55
|
|
|
57
56
|
const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
|
|
@@ -67,10 +66,8 @@
|
|
|
67
66
|
const showVals = $derived(styles.values?.show ?? false);
|
|
68
67
|
const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
|
|
69
68
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// one `<Plot>` instance (not the shared HoverStore) so the gesture can't
|
|
73
|
-
// leak into unrelated tooltip/crosshair behavior.
|
|
69
|
+
// Dims every other series like a legend toggle when a value label is
|
|
70
|
+
// hovered, local to this `<Plot>` instance.
|
|
74
71
|
let hoverIsolatedSeries = $state<string | null>(null);
|
|
75
72
|
|
|
76
73
|
function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
|
|
@@ -85,10 +82,7 @@
|
|
|
85
82
|
const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
|
|
86
83
|
const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
|
|
87
84
|
const flat = $derived(resolvedSeries.flatMap((s) => s.data));
|
|
88
|
-
//
|
|
89
|
-
// in hover/tooltip — its points are simply absent from the candidate list.
|
|
90
|
-
// A gap row (see `gaps`) is excluded the same way — it carries no real
|
|
91
|
-
// value to hover.
|
|
85
|
+
// Legend-disabled series and gap rows are excluded from hover candidates.
|
|
92
86
|
const hoverPoints = $derived(
|
|
93
87
|
resolvedSeries.flatMap((s, idx) => {
|
|
94
88
|
if (legendInteraction?.disabledSeries.has(s.name)) return [];
|
|
@@ -156,13 +150,7 @@
|
|
|
156
150
|
.filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined),
|
|
157
151
|
);
|
|
158
152
|
|
|
159
|
-
//
|
|
160
|
-
// margin — reserving real space there (rather than padding the domain,
|
|
161
|
-
// which would falsely imply data exists past the last real point) is
|
|
162
|
-
// what ValueLabels's own overflow measurement feeds into here, via a
|
|
163
|
-
// tracker local to this one `<Plot>` instance — see ValueLabels.svelte's
|
|
164
|
-
// own doc comment for why that locality (vs. svelteplot's shared margin
|
|
165
|
-
// context) matters.
|
|
153
|
+
// Grows the plot's margin to fit value labels that overflow the content box.
|
|
166
154
|
const labelMargin = createLabelMarginTracker(() => margins);
|
|
167
155
|
</script>
|
|
168
156
|
|
|
@@ -13,12 +13,8 @@
|
|
|
13
13
|
import type { MarginOverflow } from '../utils/labelOverflow.svelte';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* End-
|
|
17
|
-
*
|
|
18
|
-
* end up with close last values. Rendered as a proper descendant of
|
|
19
|
-
* svelteplot's `<Plot>` (not just of `LinePlot` itself) so `usePlot()`
|
|
20
|
-
* below resolves the real, live y-scale needed to measure pixel-space
|
|
21
|
-
* overlap.
|
|
16
|
+
* End-of-line value labels for `LinePlot`, with automatic collision
|
|
17
|
+
* resolution and shared horizontal alignment across labels.
|
|
22
18
|
*/
|
|
23
19
|
let {
|
|
24
20
|
lastPoints,
|
|
@@ -33,7 +29,7 @@
|
|
|
33
29
|
}: {
|
|
34
30
|
lastPoints: { series: Series<TData>; lastRow: TData; color: string }[];
|
|
35
31
|
values: ValuesStyle | undefined;
|
|
36
|
-
/**
|
|
32
|
+
/** Plot pixel size, used with `numericMargins` to compute the content box. */
|
|
37
33
|
width: number;
|
|
38
34
|
height: number;
|
|
39
35
|
/** `BasePlotLayout`'s resolved margins, already numeric on every side. */
|
|
@@ -49,17 +45,10 @@
|
|
|
49
45
|
|
|
50
46
|
const cfg = $derived(getConfiguration());
|
|
51
47
|
|
|
52
|
-
// Elbow-routed leader line geometry (pixels), configurable
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// the last; `elbowGap` is the shortest lane (rank 0, right off the data
|
|
57
|
-
// point); `labelGap` is the small clearance between the outermost lane and
|
|
58
|
-
// the label text it feeds into. `connectorColor`/`connectorWidth` are the
|
|
59
|
-
// leader line's own default look — deliberately *not* each series' own
|
|
60
|
-
// colour or stroke weight (matching the data line reads as more data, not
|
|
61
|
-
// as a leader, right where several lines are already converging) — both
|
|
62
|
-
// still overridable per-plot via `values.strokeStyle`.
|
|
48
|
+
// Elbow-routed leader line geometry (pixels), configurable via
|
|
49
|
+
// `setConfiguration({ line: { valueLabels: {...} } })`. `connectorColor`/
|
|
50
|
+
// `connectorWidth` default to a neutral look rather than each series' own
|
|
51
|
+
// colour, overridable per-plot via `values.strokeStyle`.
|
|
63
52
|
const elbowGap = $derived(cfg.line.valueLabels.elbowGap);
|
|
64
53
|
const laneStep = $derived(cfg.line.valueLabels.laneStep);
|
|
65
54
|
const labelGap = $derived(cfg.line.valueLabels.labelGap);
|
|
@@ -70,12 +59,12 @@
|
|
|
70
59
|
const connector = $derived(values?.strokeStyle);
|
|
71
60
|
const formatValue = $derived(values?.format ?? ((v: number, name: string) => `${name} - ${v}`));
|
|
72
61
|
const valAnchor = $derived<ValueAnchor>(values?.anchor ?? 'start');
|
|
73
|
-
// 'outside' is
|
|
74
|
-
// value labels already have their own `anchor: 'outside'` concept above,
|
|
75
|
-
// so treat it as unset here.
|
|
62
|
+
// 'outside' is handled separately by HoverMarker, so it's treated as unset here.
|
|
76
63
|
const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
|
|
77
64
|
|
|
78
65
|
const defaultDx = $derived(valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0);
|
|
66
|
+
const baseDx = $derived(font?.dx ?? defaultDx);
|
|
67
|
+
const dir = $derived(Math.sign(baseDx) || 1);
|
|
79
68
|
const defaultDy = $derived(valAnchor === 'outside' ? -8 : 0);
|
|
80
69
|
const defaultTA = $derived(
|
|
81
70
|
valAnchor === 'start' ? ('start' as const) : valAnchor === 'end' ? ('end' as const) : ('middle' as const),
|
|
@@ -85,38 +74,21 @@
|
|
|
85
74
|
|
|
86
75
|
const plot = usePlot();
|
|
87
76
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// registering directly into svelteplot's *shared* margin-auto-sizing
|
|
94
|
-
// context keeps this entirely local to one `<Plot>` instance: nothing here
|
|
95
|
-
// is shared across instances, which is what made that old approach spike
|
|
96
|
-
// CPU/memory into unresponsiveness when many instances mounted/unmounted
|
|
97
|
-
// together (e.g. a multi-story docs page → single story).
|
|
77
|
+
// Measures how far the rendered labels extend past the plot's content box
|
|
78
|
+
// and reports it to the caller (`Plot.svelte`), which grows its own local
|
|
79
|
+
// margin state to fit. Reported via a plain callback rather than
|
|
80
|
+
// svelteplot's shared margin context, to keep it local per `<Plot>`
|
|
81
|
+
// instance.
|
|
98
82
|
let groupEl: SVGGElement | undefined = $state();
|
|
99
83
|
|
|
100
84
|
watchLabelOverflow({
|
|
101
85
|
el: () => groupEl,
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// doc comment), not from `usePlot().scales.x/y.fn.range()`: svelteplot's
|
|
106
|
-
// `<Plot>` accepts an explicit `width`/`height` (our `fixedWidth`) for
|
|
107
|
-
// its own real layout, but the scale object it exposes through context
|
|
108
|
-
// is computed off its *internal* auto-detected size instead — normally
|
|
109
|
-
// the same number, but never reconciled with `fixedWidth` when one is
|
|
110
|
-
// given. That left `.range()` reporting a stale/placeholder boundary
|
|
111
|
-
// (svelteplot's own internal default, unrelated to this plot's actual
|
|
112
|
-
// pixel size) wherever a caller fixes `width`/`height` explicitly — which
|
|
113
|
-
// every plot kind here does — so this watcher never saw genuine
|
|
114
|
-
// right/bottom overflow past the *real* edge and never grew the margin
|
|
115
|
-
// to fit it, leaving end-point labels to render past the plot's true
|
|
116
|
-
// right edge with no space reserved for them.
|
|
86
|
+
// Built from `width`/`height`/`numericMargins` rather than the live
|
|
87
|
+
// scale's own `.range()`, which doesn't reconcile with an explicitly
|
|
88
|
+
// sized `<Plot>`.
|
|
117
89
|
bounds: () => {
|
|
118
|
-
// getBBox()
|
|
119
|
-
//
|
|
90
|
+
// getBBox() isn't tracked reactively — depend on whatever changes the
|
|
91
|
+
// label layout so this re-measures.
|
|
120
92
|
void lastPoints;
|
|
121
93
|
void values;
|
|
122
94
|
return {
|
|
@@ -130,50 +102,88 @@
|
|
|
130
102
|
});
|
|
131
103
|
|
|
132
104
|
/**
|
|
133
|
-
* Pixel-space collision fix-up for end-point labels: resolves each
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* which
|
|
140
|
-
* autoscale ↔ declutter feedback loop.
|
|
105
|
+
* Pixel-space collision fix-up for end-point labels: resolves each
|
|
106
|
+
* label's pixel Y through the live y-scale, then declutters those pixels
|
|
107
|
+
* to find how far each needs to move to clear its neighbours and stay
|
|
108
|
+
* within the plot's own top/bottom edge (`extraDy` — applies even to a
|
|
109
|
+
* lone label with no neighbours, if its own value sits close enough to
|
|
110
|
+
* the edge). `rank`/`lanesUsed`/`runSize` come from `laneAssignment`,
|
|
111
|
+
* which turns the decluttered runs into lane assignments.
|
|
141
112
|
*
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
113
|
+
* Horizontal placement (`aligned`/`reach`/`laneReach`/`dir`) is separate:
|
|
114
|
+
* every label reaches out to the same shared lane column, not just the
|
|
115
|
+
* ones in its own cluster, so the whole set of end-of-line labels lines
|
|
116
|
+
* up on one horizontal line. A label gets a connector whenever either of
|
|
117
|
+
* these moved it off its natural position — see the markup below.
|
|
118
|
+
*
|
|
119
|
+
* That shared column's direction can itself flip from `dir` to the right,
|
|
120
|
+
* but only ever away from the left: a leftward fan (`dir === -1`) runs
|
|
121
|
+
* into the Y axis's own tick labels once every point sits pinned against
|
|
122
|
+
* that same edge (e.g. a scroll/scrub caller's first visible point), with
|
|
123
|
+
* nowhere to reserve room without growing a margin purely to sit on top of
|
|
124
|
+
* them. The right has no such fixed content by default, so flipping there
|
|
125
|
+
* instead — for every aligned label at once — avoids the collision
|
|
126
|
+
* without needing any extra margin at all. The mirror case is deliberately
|
|
127
|
+
* left alone: a rightward fan's point is *always* pinned at the scale's
|
|
128
|
+
* own right edge by construction (`available` below would read ~0 for it
|
|
129
|
+
* too, same as the left), so the same check would flip the common case
|
|
130
|
+
* right back into the plot over its own lines — margin growth (see
|
|
131
|
+
* `Plot.svelte`'s `labelMargin`) is what actually handles that side.
|
|
151
132
|
*/
|
|
152
133
|
const declutteredOffsets = $derived.by(() => {
|
|
153
|
-
const map = new SvelteMap<
|
|
134
|
+
const map = new SvelteMap<
|
|
135
|
+
string,
|
|
136
|
+
{ extraDy: number; aligned: boolean; rank: number; reach: number; laneReach: number; dir: number }
|
|
137
|
+
>();
|
|
154
138
|
if (lastPoints.length < 2) return map;
|
|
155
139
|
const yScale = plot.scales.y?.fn;
|
|
156
140
|
if (!yScale) return map;
|
|
141
|
+
const xScale = plot.scales.x?.fn;
|
|
157
142
|
const minGap = Math.max(16, fontSize * 1.4);
|
|
158
143
|
const items = lastPoints.map(({ series: s, lastRow }) => ({
|
|
159
144
|
name: s.name,
|
|
160
145
|
pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
|
|
146
|
+
px: xScale ? Number(xScale(resolveAccessor(s.x)(lastRow))) : undefined,
|
|
161
147
|
}));
|
|
162
148
|
const sorted = [...items].sort((a, b) => a.pos - b.pos);
|
|
163
|
-
// Same content-box source as `watchLabelOverflow`'s `bounds()` above
|
|
164
|
-
// not `yScale.range()` — see that call's doc comment for why.
|
|
149
|
+
// Same content-box source as `watchLabelOverflow`'s `bounds()` above.
|
|
165
150
|
const bounds: [number, number] = [numericMargins.top, height - numericMargins.bottom];
|
|
166
151
|
const { positions: spread, connected } = declutter1D(items, minGap, bounds);
|
|
167
152
|
const spreadByName = new Map(spread.map((d) => [d.name, d.pos]));
|
|
168
153
|
const originals = sorted.map((item) => item.pos);
|
|
169
154
|
const finals = sorted.map((item) => spreadByName.get(item.name) ?? item.pos);
|
|
170
155
|
const ranks = laneAssignment(originals, finals, connected);
|
|
156
|
+
|
|
157
|
+
const anyGrouped = ranks.some((r) => r.runSize > 1);
|
|
158
|
+
const globalLanes = anyGrouped ? Math.max(...ranks.map((r) => r.lanesUsed)) : 1;
|
|
159
|
+
const reach = elbowGap + (globalLanes - 1) * laneStep + labelGap;
|
|
160
|
+
|
|
161
|
+
let effectiveDir = dir;
|
|
162
|
+
if (anyGrouped && dir === -1) {
|
|
163
|
+
// -Infinity (not +Infinity) when the x-scale isn't resolved yet on an
|
|
164
|
+
// early render — defaults to flipped until proven safe not to be,
|
|
165
|
+
// rather than the other way around. Defaulting to "don't flip" here
|
|
166
|
+
// briefly rendered the full leftward fan with no available-space data
|
|
167
|
+
// at all, which `watchLabelOverflow` could catch and lock in via its
|
|
168
|
+
// own monotonic growth before a later render corrected it — flipping
|
|
169
|
+
// right is never wrong even when it turns out to be unnecessary (nothing
|
|
170
|
+
// fixed lives there to run into), so there's no equivalent risk on
|
|
171
|
+
// that side to defend against.
|
|
172
|
+
const roomsLeft = sorted
|
|
173
|
+
.filter((item): item is typeof item & { px: number } => item.px != null)
|
|
174
|
+
.map((item) => item.px - numericMargins.left);
|
|
175
|
+
const availableLeft = roomsLeft.length > 0 ? Math.min(...roomsLeft) : -Infinity;
|
|
176
|
+
if (availableLeft < reach) effectiveDir = 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
171
179
|
sorted.forEach((item, i) => {
|
|
172
180
|
map.set(item.name, {
|
|
173
181
|
extraDy: finals[i] - originals[i],
|
|
174
|
-
|
|
182
|
+
aligned: anyGrouped,
|
|
175
183
|
rank: ranks[i].rank,
|
|
176
|
-
|
|
184
|
+
reach,
|
|
185
|
+
laneReach: elbowGap + ranks[i].rank * laneStep,
|
|
186
|
+
dir: effectiveDir,
|
|
177
187
|
});
|
|
178
188
|
});
|
|
179
189
|
return map;
|
|
@@ -181,27 +191,38 @@
|
|
|
181
191
|
</script>
|
|
182
192
|
|
|
183
193
|
<g bind:this={groupEl}>
|
|
194
|
+
<!-- Nothing renders before both scales resolve: `declutteredOffsets` itself
|
|
195
|
+
only needs the y-scale, so an early render with the x-scale still
|
|
196
|
+
missing would otherwise fall back to plain `baseDx`, un-flipped — for a
|
|
197
|
+
long 'end'-anchored label that's a real, if brief, overflow past the
|
|
198
|
+
edge, and `watchLabelOverflow` can catch and lock that in via its own
|
|
199
|
+
monotonic growth before a later, correctly-positioned render arrives. -->
|
|
200
|
+
{#if plot.scales.x?.fn && plot.scales.y?.fn}
|
|
184
201
|
{#each lastPoints as { series: s, lastRow, color: seriesColor } (s.name)}
|
|
185
202
|
{@const xFn = resolveAccessor(s.x)}
|
|
186
203
|
{@const yFn = resolveAccessor(s.y)}
|
|
187
204
|
{@const offset = declutteredOffsets.get(s.name)}
|
|
188
|
-
{@const extraDy = offset?.
|
|
189
|
-
{@const
|
|
190
|
-
{@const
|
|
191
|
-
{@const
|
|
192
|
-
{@const labelDx = offset?.inGroup
|
|
193
|
-
? dir * (elbowGap + (numLanes - 1) * laneStep + labelGap)
|
|
194
|
-
: baseDx}
|
|
205
|
+
{@const extraDy = offset?.extraDy ?? 0}
|
|
206
|
+
{@const effDir = offset?.aligned ? offset.dir : dir}
|
|
207
|
+
{@const flipped = offset?.aligned && effDir !== dir}
|
|
208
|
+
{@const labelDx = offset?.aligned ? effDir * offset.reach : baseDx}
|
|
195
209
|
{@const text = formatValue(Number(yFn(lastRow)), s.name)}
|
|
196
210
|
{@const preferredTA = resolvedTextAnchor ?? defaultTA}
|
|
211
|
+
{@const finalTA = flipped
|
|
212
|
+
? preferredTA === 'start'
|
|
213
|
+
? 'end'
|
|
214
|
+
: preferredTA === 'end'
|
|
215
|
+
? 'start'
|
|
216
|
+
: preferredTA
|
|
217
|
+
: preferredTA}
|
|
197
218
|
{@const preferredLA = font?.lineAnchor ?? defaultLA}
|
|
198
219
|
{@const disabled = disabledFor?.(s.name)}
|
|
199
220
|
{@const disabledStroke = disabledStrokeOverride(disabled)}
|
|
200
221
|
{@const disabledFill = disabledFillOverride(disabled)}
|
|
201
|
-
{#if offset?.
|
|
222
|
+
{#if offset?.aligned || extraDy !== 0}
|
|
202
223
|
{@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
|
|
203
224
|
{@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
|
|
204
|
-
{@const laneX = px +
|
|
225
|
+
{@const laneX = offset?.aligned ? px + effDir * offset.laneReach : px + labelDx}
|
|
205
226
|
{@const targetY = py + extraDy}
|
|
206
227
|
<path
|
|
207
228
|
d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
|
|
@@ -214,9 +235,7 @@
|
|
|
214
235
|
stroke-linejoin={connector?.strokeLinejoin ?? 'miter'}
|
|
215
236
|
/>
|
|
216
237
|
{/if}
|
|
217
|
-
<!--
|
|
218
|
-
despite declaring them in its types — a wrapping <g> catches the
|
|
219
|
-
boundary crossing instead (pointerenter/leave fire on every ancestor). -->
|
|
238
|
+
<!-- Text marks don't wire pointer handlers; a wrapping <g> catches them instead. -->
|
|
220
239
|
<g
|
|
221
240
|
role="presentation"
|
|
222
241
|
onpointerenter={onHoverSeries && (() => onHoverSeries(s.name))}
|
|
@@ -229,7 +248,7 @@
|
|
|
229
248
|
text={() => text}
|
|
230
249
|
dx={labelDx}
|
|
231
250
|
dy={(font?.dy ?? defaultDy) + extraDy}
|
|
232
|
-
textAnchor={
|
|
251
|
+
textAnchor={finalTA}
|
|
233
252
|
lineAnchor={preferredLA}
|
|
234
253
|
lineHeight={font?.lineHeight}
|
|
235
254
|
rotate={font?.rotate}
|
|
@@ -246,4 +265,5 @@
|
|
|
246
265
|
/>
|
|
247
266
|
</g>
|
|
248
267
|
{/each}
|
|
268
|
+
{/if}
|
|
249
269
|
</g>
|
|
@@ -10,7 +10,7 @@ declare function $$render<TData extends Record<string, unknown>>(): {
|
|
|
10
10
|
color: string;
|
|
11
11
|
}[];
|
|
12
12
|
values: ValuesStyle | undefined;
|
|
13
|
-
/**
|
|
13
|
+
/** Plot pixel size, used with `numericMargins` to compute the content box. */
|
|
14
14
|
width: number;
|
|
15
15
|
height: number;
|
|
16
16
|
/** `BasePlotLayout`'s resolved margins, already numeric on every side. */
|
|
@@ -103,10 +103,10 @@ function runBiasWeights(down, up, connected, [lo, hi]) {
|
|
|
103
103
|
* it lands entirely inside `[lo, hi]`. A run's own relative spacing is
|
|
104
104
|
* preserved as-is (just translated) whenever it already fits in `hi - lo`;
|
|
105
105
|
* only a run wider than the available space gets uniformly scaled down
|
|
106
|
-
* around its centre first.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
106
|
+
* around its centre first. A lone, unconnected position is simply clamped
|
|
107
|
+
* to the nearer bound instead — its own value never needed decluttering,
|
|
108
|
+
* but it can still sit close enough to the scale's own edge to overflow on
|
|
109
|
+
* its own.
|
|
110
110
|
*/
|
|
111
111
|
function fitRunsWithinBounds(positions, connected, [lo, hi]) {
|
|
112
112
|
const out = [...positions];
|
|
@@ -130,6 +130,9 @@ function fitRunsWithinBounds(positions, connected, [lo, hi]) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
else {
|
|
134
|
+
out[i] = Math.min(Math.max(out[i], lo), hi);
|
|
135
|
+
}
|
|
133
136
|
i = j + 1;
|
|
134
137
|
}
|
|
135
138
|
return out;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
1
2
|
const NO_OVERFLOW = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
2
3
|
/**
|
|
3
4
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
@@ -94,19 +95,27 @@ export function createLabelMarginTracker(margins) {
|
|
|
94
95
|
measured = next;
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
// `measured` is overflow *past* the current margin, not the total margin
|
|
99
|
+
// needed — grown on top of `AUTO_MARGIN_ESTIMATE` (the same fallback
|
|
100
|
+
// svelteplot's own 'auto' sizing is estimated by elsewhere), not the raw
|
|
101
|
+
// overflow alone, or growth would silently replace whatever room 'auto'
|
|
102
|
+
// would have reserved for the axis itself (tick labels, title) with a
|
|
103
|
+
// number that only accounts for the value label. `AUTO_MARGIN_ESTIMATE` is
|
|
104
|
+
// a fixed constant, not `numericMargins`' own already-grown value, so this
|
|
105
|
+
// can't compound into unbounded growth across repeated reports.
|
|
97
106
|
const resolvedMargins = $derived.by(() => {
|
|
98
107
|
const base = margins();
|
|
99
108
|
if (measured.left <= 0 && measured.right <= 0 && measured.top <= 0 && measured.bottom <= 0)
|
|
100
109
|
return base;
|
|
101
110
|
const out = { ...base };
|
|
102
111
|
if (measured.right > 0 && base?.right === undefined)
|
|
103
|
-
out.right = measured.right;
|
|
112
|
+
out.right = AUTO_MARGIN_ESTIMATE.right + measured.right;
|
|
104
113
|
if (measured.left > 0 && base?.left === undefined)
|
|
105
|
-
out.left = measured.left;
|
|
114
|
+
out.left = AUTO_MARGIN_ESTIMATE.left + measured.left;
|
|
106
115
|
if (measured.top > 0 && base?.top === undefined)
|
|
107
|
-
out.top = measured.top;
|
|
116
|
+
out.top = AUTO_MARGIN_ESTIMATE.top + measured.top;
|
|
108
117
|
if (measured.bottom > 0 && base?.bottom === undefined)
|
|
109
|
-
out.bottom = measured.bottom;
|
|
118
|
+
out.bottom = AUTO_MARGIN_ESTIMATE.bottom + measured.bottom;
|
|
110
119
|
return out;
|
|
111
120
|
});
|
|
112
121
|
// Bundles `resolvedMargins` with a remount key derived from `measured`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GeoProjection } from 'd3-geo';
|
|
2
|
+
import type { GeoCameraConfig } from '../plots/camera';
|
|
2
3
|
/**
|
|
3
4
|
* The projection names svelteplot's `<Plot projection>` accepts as a string
|
|
4
5
|
* (svelteplot itself doesn't export this as a type — defined here so authors
|
|
@@ -53,6 +54,7 @@ export type GeoScalesConfig = {
|
|
|
53
54
|
domain?: [number, number];
|
|
54
55
|
};
|
|
55
56
|
};
|
|
57
|
+
/** Wheel/pinch scale gesture only — see `CoordinatesConfig.pan` for drag-to-translate, and `.rotate` for drag-to-spin-the-globe. */
|
|
56
58
|
export type GeoZoomConfig = {
|
|
57
59
|
min?: number;
|
|
58
60
|
max?: number;
|
|
@@ -63,14 +65,18 @@ export type GeoZoomConfig = {
|
|
|
63
65
|
};
|
|
64
66
|
};
|
|
65
67
|
/**
|
|
66
|
-
* `coordinates` prop for GeoPlot — the
|
|
67
|
-
*
|
|
68
|
-
* Coordinates concern) and pan/zoom (navigating within that coordinate
|
|
69
|
-
* system), resolved together by `CoordinatesLayout`.
|
|
68
|
+
* `coordinates` prop for GeoPlot — bundles the projection, interactive
|
|
69
|
+
* gestures, and the declarative `camera`, resolved by `CoordinatesLayout`.
|
|
70
70
|
*/
|
|
71
71
|
export type CoordinatesConfig = {
|
|
72
72
|
/** Which map projection to draw with. Defaults to `'equal-earth'`. */
|
|
73
73
|
projection?: GeoProjectionConfig;
|
|
74
|
-
/** `true`/config enables
|
|
74
|
+
/** `true`/config enables scroll/pinch-to-zoom. Independent of `pan`/`rotate`. */
|
|
75
75
|
zoom?: boolean | GeoZoomConfig;
|
|
76
|
+
/** `true` enables drag-to-pan on a 2D (non-`orthographic`) projection. */
|
|
77
|
+
pan?: boolean;
|
|
78
|
+
/** `true` enables drag-to-rotate on `orthographic` (claims the drag gesture from `pan`). */
|
|
79
|
+
rotate?: boolean;
|
|
80
|
+
/** `GeoPlot`'s own declarative "current viewpoint" — frame/zoom/anchor on a target, animated on change. See `GeoCameraConfig`. */
|
|
81
|
+
camera?: GeoCameraConfig;
|
|
76
82
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { EasingName, EasingFn } from '../charts/interpolate';
|
|
2
|
+
import type { GeoInsetLocation } from '../markers/geo';
|
|
3
|
+
/** What the camera frames. `features` shares its id space with `segments`/`featureId`. `center` has no extent of its own — see `GeoCameraConfig.zoom`. */
|
|
4
|
+
export type GeoCameraTarget = {
|
|
5
|
+
features: string[];
|
|
6
|
+
} | {
|
|
7
|
+
center: [number, number];
|
|
8
|
+
} | {
|
|
9
|
+
bounds: GeoJSON.GeoJsonObject;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* How the camera moves to a newly resolved target.
|
|
13
|
+
* - `progress` unset: auto-plays once over `duration` ms, eased by `easing`.
|
|
14
|
+
* - `progress` set (a live 0–1 value, e.g. from scroll): renders the move as
|
|
15
|
+
* a pure function of it instead — stalls/reverses with the input.
|
|
16
|
+
*/
|
|
17
|
+
export type GeoCameraTransition = {
|
|
18
|
+
/** Auto-play duration in ms, ignored once `progress` is set. Defaults to 750. */
|
|
19
|
+
duration?: number;
|
|
20
|
+
/** Same `EasingName | EasingFn` vocabulary as `styles.gaps`/`TimelineConfig.interpolate`. */
|
|
21
|
+
easing?: EasingName | EasingFn;
|
|
22
|
+
/** A live 0–1 value the host drives itself instead of an auto-played duration. */
|
|
23
|
+
progress?: number;
|
|
24
|
+
/** Which way to interpolate rotation when ambiguous — only relevant for `orthographic`. `'shortest'` (default) or `'longest'`. No-op for 2D projections. */
|
|
25
|
+
direction?: 'shortest' | 'longest';
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* `coordinates.camera` — `GeoPlot`'s declarative "current viewpoint": frames,
|
|
29
|
+
* zooms and anchors on `target`, animated on change. Realized as a
|
|
30
|
+
* pixel-space pan/zoom transform for 2D projections, plus a `rotate`
|
|
31
|
+
* override for `orthographic`. Composes with interactive `coordinates.pan`/`zoom`/`rotate`.
|
|
32
|
+
*/
|
|
33
|
+
export type GeoCameraConfig = {
|
|
34
|
+
target: GeoCameraTarget;
|
|
35
|
+
/** Multiplier on the auto-fit scale for a `features`/`bounds` target (`1` = tight fit). For a `center` target, used directly as the pixel scale instead. */
|
|
36
|
+
zoom?: number;
|
|
37
|
+
/** Where the framed target lands within the plot frame — the same 9-point vocabulary `inset` markers use. Defaults to `'middle-middle'`. */
|
|
38
|
+
anchor?: GeoInsetLocation;
|
|
39
|
+
/** Pixel gap kept between the framed target and whichever edge(s) `anchor` pushes it toward. Defaults to 24. */
|
|
40
|
+
padding?: number;
|
|
41
|
+
transition?: GeoCameraTransition;
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|