@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.
@@ -49,9 +49,8 @@
49
49
  tooltip = undefined,
50
50
  }: Props = $props();
51
51
 
52
- // Fills leading/internal/trailing missing-data holes with an
53
- // interpolated, distinctly-styled bridge, resolved per series like
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
- // A value label hovered while `styles.values.hoverIsolate` is on dims
71
- // every other series exactly like a legend toggle would — local to this
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
- // A legend-disabled series is dimmed on the mark itself, but never surfaces
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
- // End-of-line labels can grow past the plot's own content box, into its
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-point value labels for `LinePlot` one label per series at its last
17
- * data point, with automatic collision resolution whenever several series
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
- /** The plot's own pixel size together with `numericMargins`, the ground truth for its content box (see `bounds()` below for why `usePlot()`'s own scale range can't be trusted for this). */
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 project-wide
53
- // via `setConfiguration({ line: { valueLabels: {...} } })` — each lane a
54
- // run's members get spread across (see `laneAssignment`'s doc comment for
55
- // how lanes are picked without crossing) is `laneStep` further out than
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 a per-point escape hatch resolved by HoverMarker; line's own
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
- // ── Overflow measurement ─────────────────────────────────────────────────
89
- // Measures how far the rendered labels extend past the plot's own content
90
- // box and reports it to the caller (`Plot.svelte`), which grows its own
91
- // local margin state to fit see `labelOverflow.svelte.ts` for the actual
92
- // `getBBox()` measurement. Reporting through a plain callback instead of
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
- // Deliberately built from our own `width`/`height`/`numericMargins`
103
- // (all already correctly sized off the `width`/`height` this `<Plot>`
104
- // was actually mounted with — see `BasePlotLayout`'s own `numericMargins`
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() inside isn't itself tracked — depend on whatever actually
119
- // changes the rendered label layout so this re-measures.
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 label's
134
- * true pixel Y through the plot's own live y-scale, then runs the shared
135
- * two-pass declutter over those pixels to find how far each one needs to
136
- * move to clear its neighbours. The result is a pure pixel *offset*
137
- * (`extraDy`), applied the same way `<Text dy>` already is everywhere else
138
- * in this file it never becomes a data-space Y fed back into a mark,
139
- * which would extend the y-scale's own domain and provoke an infinite
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
- * `inGroup`/`rank`/`lanesUsed` come from `laneAssignment`, fed by
143
- * `declutter1D`'s own `connected` record rather than re-inferred from
144
- * positions see both functions' doc comments for why that's the only
145
- * reliable source: position-based heuristics either miss a series that
146
- * only had to move because a neighbour got pushed into it, or wrongly
147
- * bridge two unrelated clusters that each happened to need decluttering
148
- * on their own. The elbow connector below turns `rank` into a lane index.
149
- * A series with nothing nearby is absent from the map, and its label
150
- * renders with no extra offset.
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<string, { extraDy: number; inGroup: boolean; rank: number; lanesUsed: number }>();
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
- inGroup: ranks[i].runSize > 1,
182
+ aligned: anyGrouped,
175
183
  rank: ranks[i].rank,
176
- lanesUsed: ranks[i].lanesUsed,
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?.inGroup ? offset.extraDy : 0}
189
- {@const baseDx = font?.dx ?? defaultDx}
190
- {@const dir = Math.sign(baseDx) || 1}
191
- {@const numLanes = offset?.inGroup ? offset.lanesUsed : 0}
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?.inGroup && plot.scales.x?.fn && plot.scales.y?.fn}
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 + dir * (elbowGap + offset.rank * laneStep)}
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
- <!-- svelteplot's Text mark never wires pointer handlers to its DOM node,
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={preferredTA}
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
- /** The plot's own pixel size together with `numericMargins`, the ground truth for its content box (see `bounds()` below for why `usePlot()`'s own scale range can't be trusted for this). */
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. Lone, unconnected positions pass through
107
- * untouched they were never pushed by `declutter1D` in the first place,
108
- * so clamping one to the bounds would move a label away from the data point
109
- * it's honestly reporting, for no collision-avoidance reason at all.
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 only plot kind with a coordinate
67
- * system other than implicit cartesian. Bundles the projection (a `GoG`
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 drag-pan + scroll-zoom. Omitted/`false` = static map. */
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"