@fundar/data-chart-telling 0.0.35 → 0.0.37

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,27 +102,20 @@
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 (`extraDy`).
108
+ * `rank`/`lanesUsed`/`runSize` come from `laneAssignment`, which turns the
109
+ * decluttered runs into lane assignments.
141
110
  *
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.
111
+ * Every label reaches out to the same shared lane column (`reach`/
112
+ * `laneReach`), not just the ones in its own cluster, so the whole set of
113
+ * end-of-line labels lines up on one horizontal line. A label that didn't
114
+ * need to move for collision avoidance still gets a connector to cover
115
+ * the resulting gap see the `aligned` gate in the markup below.
151
116
  */
152
117
  const declutteredOffsets = $derived.by(() => {
153
- const map = new SvelteMap<string, { extraDy: number; inGroup: boolean; rank: number; lanesUsed: number }>();
118
+ const map = new SvelteMap<string, { extraDy: number; aligned: boolean; rank: number; reach: number; laneReach: number }>();
154
119
  if (lastPoints.length < 2) return map;
155
120
  const yScale = plot.scales.y?.fn;
156
121
  if (!yScale) return map;
@@ -160,21 +125,25 @@
160
125
  pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
161
126
  }));
162
127
  const sorted = [...items].sort((a, b) => a.pos - b.pos);
163
- const range = yScale.range?.().map(Number);
164
- const bounds: [number, number] | undefined = range
165
- ? [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
166
- : undefined;
128
+ // Same content-box source as `watchLabelOverflow`'s `bounds()` above.
129
+ const bounds: [number, number] = [numericMargins.top, height - numericMargins.bottom];
167
130
  const { positions: spread, connected } = declutter1D(items, minGap, bounds);
168
131
  const spreadByName = new Map(spread.map((d) => [d.name, d.pos]));
169
132
  const originals = sorted.map((item) => item.pos);
170
133
  const finals = sorted.map((item) => spreadByName.get(item.name) ?? item.pos);
171
134
  const ranks = laneAssignment(originals, finals, connected);
135
+
136
+ const anyGrouped = ranks.some((r) => r.runSize > 1);
137
+ const globalLanes = anyGrouped ? Math.max(...ranks.map((r) => r.lanesUsed)) : 1;
138
+ const reach = elbowGap + (globalLanes - 1) * laneStep + labelGap;
139
+
172
140
  sorted.forEach((item, i) => {
173
141
  map.set(item.name, {
174
142
  extraDy: finals[i] - originals[i],
175
- inGroup: ranks[i].runSize > 1,
143
+ aligned: anyGrouped,
176
144
  rank: ranks[i].rank,
177
- lanesUsed: ranks[i].lanesUsed,
145
+ reach,
146
+ laneReach: elbowGap + ranks[i].rank * laneStep,
178
147
  });
179
148
  });
180
149
  return map;
@@ -186,23 +155,18 @@
186
155
  {@const xFn = resolveAccessor(s.x)}
187
156
  {@const yFn = resolveAccessor(s.y)}
188
157
  {@const offset = declutteredOffsets.get(s.name)}
189
- {@const extraDy = offset?.inGroup ? offset.extraDy : 0}
190
- {@const baseDx = font?.dx ?? defaultDx}
191
- {@const dir = Math.sign(baseDx) || 1}
192
- {@const numLanes = offset?.inGroup ? offset.lanesUsed : 0}
193
- {@const labelDx = offset?.inGroup
194
- ? dir * (elbowGap + (numLanes - 1) * laneStep + labelGap)
195
- : baseDx}
158
+ {@const extraDy = offset?.aligned ? offset.extraDy : 0}
159
+ {@const labelDx = offset?.aligned ? dir * offset.reach : baseDx}
196
160
  {@const text = formatValue(Number(yFn(lastRow)), s.name)}
197
161
  {@const preferredTA = resolvedTextAnchor ?? defaultTA}
198
162
  {@const preferredLA = font?.lineAnchor ?? defaultLA}
199
163
  {@const disabled = disabledFor?.(s.name)}
200
164
  {@const disabledStroke = disabledStrokeOverride(disabled)}
201
165
  {@const disabledFill = disabledFillOverride(disabled)}
202
- {#if offset?.inGroup && plot.scales.x?.fn && plot.scales.y?.fn}
166
+ {#if offset?.aligned && plot.scales.x?.fn && plot.scales.y?.fn}
203
167
  {@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
204
168
  {@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
205
- {@const laneX = px + dir * (elbowGap + offset.rank * laneStep)}
169
+ {@const laneX = px + dir * offset.laneReach}
206
170
  {@const targetY = py + extraDy}
207
171
  <path
208
172
  d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
@@ -215,9 +179,7 @@
215
179
  stroke-linejoin={connector?.strokeLinejoin ?? 'miter'}
216
180
  />
217
181
  {/if}
218
- <!-- svelteplot's Text mark never wires pointer handlers to its DOM node,
219
- despite declaring them in its types — a wrapping <g> catches the
220
- boundary crossing instead (pointerenter/leave fire on every ancestor). -->
182
+ <!-- Text marks don't wire pointer handlers; a wrapping <g> catches them instead. -->
221
183
  <g
222
184
  role="presentation"
223
185
  onpointerenter={onHoverSeries && (() => onHoverSeries(s.name))}
@@ -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. */
@@ -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.35",
3
+ "version": "0.0.37",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"