@fundar/data-chart-telling 1.3.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [1.5.0] - 2026-10-31
6
+
7
+ ### Changed
8
+
9
+ - Zooming or panning a map no longer re-projects every feature on each wheel tick or drag move. The features already drawn are carried to the new view with an SVG transform and the projection is moved, and the features redrawn, once the view has been still for 120 ms. A map of 6,000 features goes from about 340 ms to about 50 ms per wheel tick. Strokes and dots are briefly scaled while the view moves.
10
+
11
+ ### Fixed
12
+
13
+ - A series' catch-all segments that name different facets, or a facet next to the unscoped one, no longer log the warning for two catch-alls: they do not compete.
14
+
15
+ ## [1.4.0] - 2026-10-30
16
+
17
+ ### Added
18
+
19
+ - `legend.off` in the automatic legend's configuration turns series off from code, as if the reader had clicked them: a list of names (dimmed), or a map to an opacity or to a `dim`, `hide` or `omit` action; a series left out is turned back on. `legend.highlight` is its inverse, keeping the series it names and dimming the rest. A plot with facets can dim a series exactly when no panel shows it.
20
+
21
+ ### Fixed
22
+
23
+ - Value labels that fit margins measure what reaches past the plot against the area svelteplot lays the plot out by, not against the estimate an `'auto'` margin has until its axes are sized. A label inside the plot could read as overflow in that moment and grow the margins for good, so the same plot drew with different margins from one render to the next.
24
+
5
25
  ## [1.3.0] - 2026-10-30
6
26
 
7
27
  ### Added
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { getContext, setContext, type Snippet } from 'svelte';
2
+ import { getContext, setContext, untrack, type Snippet } from 'svelte';
3
3
  import { ZoomedProjection } from './ZoomedProjection';
4
4
  import type { GeoZoomTransform } from './zoom.svelte';
5
5
  import type { StreamingProjection } from '../../plots/utils/tiles';
@@ -10,31 +10,65 @@
10
10
  * positions, while dots, text and strokes keep their size. Works by handing
11
11
  * the marks below a view of svelteplot's plot state whose `scales.projection`
12
12
  * is the moved one.
13
+ *
14
+ * Re-projecting every mark is the cost of that, and a map with thousands of
15
+ * features pays it on each wheel tick. So while the view is moving, the marks
16
+ * already drawn are carried to it with an SVG transform (cheap, if briefly
17
+ * coarser: strokes and dots scale with it), and the projection is only moved,
18
+ * and the marks redrawn, once the view has been still for `settleDelay` ms.
13
19
  */
14
20
  let {
15
21
  inner,
16
22
  outer,
23
+ settleDelay = 120,
17
24
  children
18
- }: { inner: GeoZoomTransform; outer: GeoZoomTransform; children: Snippet } = $props();
25
+ }: {
26
+ inner: GeoZoomTransform;
27
+ outer: GeoZoomTransform;
28
+ /** Milliseconds the view must be still before the marks are re-projected */
29
+ settleDelay?: number;
30
+ children: Snippet;
31
+ } = $props();
32
+
33
+ const same = (a: GeoZoomTransform, b: GeoZoomTransform) =>
34
+ a.k === b.k && a.x === b.x && a.y === b.y;
35
+
36
+ // The view the marks are drawn for; the first one is the one they mount with.
37
+ let settled = $state.raw(untrack(() => ({ inner: { ...inner }, outer: { ...outer } })));
38
+
39
+ $effect(() => {
40
+ const next = { inner: { ...inner }, outer: { ...outer } };
41
+ if (same(next.inner, settled.inner) && same(next.outer, settled.outer)) return;
42
+ const timer = setTimeout(() => (settled = next), settleDelay);
43
+ return () => clearTimeout(timer);
44
+ });
45
+
46
+ const carry = $derived(
47
+ ZoomedProjection.between(
48
+ ZoomedProjection.compose(settled.inner, settled.outer),
49
+ ZoomedProjection.compose(inner, outer)
50
+ )
51
+ );
52
+ const carrying = $derived(carry.k !== 1 || carry.x !== 0 || carry.y !== 0);
19
53
 
20
54
  type Scales = { projection?: StreamingProjection & { aspectRatio?: number } } | undefined;
21
55
  type PlotState = { scales: Scales; publicState: object };
22
56
  type PlotContext = { getPlotState: () => PlotState } & Record<string, unknown>;
23
57
 
24
58
  const context = getContext<PlotContext>('svelteplot');
25
- const state = context.getPlotState();
59
+ const plot = context.getPlotState();
26
60
 
27
61
  const scales = $derived.by((): Scales => {
28
- const own = state.scales;
62
+ const own = plot.scales;
29
63
  return own?.projection
30
- ? { ...own, projection: ZoomedProjection.of(own.projection, inner, outer) }
64
+ ? { ...own, projection: ZoomedProjection.of(own.projection, settled.inner, settled.outer) }
31
65
  : own;
32
66
  });
33
67
 
34
- const publicState = new Proxy(state.publicState, {
68
+ const publicState = new Proxy(plot.publicState, {
35
69
  get: (target, key) => (key === 'scales' ? scales : Reflect.get(target, key, target))
36
70
  });
37
- const movedState = new Proxy(state, {
71
+ const movedState = new Proxy(plot, {
38
72
  get: (target, key) => {
39
73
  if (key === 'scales') return scales;
40
74
  if (key === 'publicState') return publicState;
@@ -45,4 +79,6 @@
45
79
  setContext('svelteplot', { ...context, getPlotState: () => movedState });
46
80
  </script>
47
81
 
48
- {@render children()}
82
+ <g transform={carrying ? `translate(${carry.x} ${carry.y}) scale(${carry.k})` : undefined}>
83
+ {@render children()}
84
+ </g>
@@ -3,6 +3,8 @@ import type { GeoZoomTransform } from './zoom.svelte';
3
3
  type $$ComponentProps = {
4
4
  inner: GeoZoomTransform;
5
5
  outer: GeoZoomTransform;
6
+ /** Milliseconds the view must be still before the marks are re-projected */
7
+ settleDelay?: number;
6
8
  children: Snippet;
7
9
  };
8
10
  declare const ProjectionZoom: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -2,6 +2,10 @@ import type { GeoZoomTransform } from './zoom.svelte';
2
2
  import type { StreamingProjection } from '../../plots/utils/tiles';
3
3
  /** A fitted projection with a pan and scale applied to where it puts things, so positions move and zoom while what is drawn at them keeps its size. */
4
4
  export declare class ZoomedProjection {
5
+ /** `outer` over `inner` as one pan and scale of the pixels. */
6
+ static compose(inner: GeoZoomTransform, outer: GeoZoomTransform): GeoZoomTransform;
7
+ /** The pan and scale that carries what is drawn through `settled` to where `live` puts it. */
8
+ static between(settled: GeoZoomTransform, live: GeoZoomTransform): GeoZoomTransform;
5
9
  /** `projection`, then `outer` over `inner` (each a pan and scale) as one move in pixels. */
6
10
  static of(projection: StreamingProjection & {
7
11
  aspectRatio?: number;
@@ -1,11 +1,18 @@
1
1
  import { geoTransform } from 'd3-geo';
2
2
  /** A fitted projection with a pan and scale applied to where it puts things, so positions move and zoom while what is drawn at them keeps its size. */
3
3
  export class ZoomedProjection {
4
+ /** `outer` over `inner` as one pan and scale of the pixels. */
5
+ static compose(inner, outer) {
6
+ return { k: inner.k * outer.k, x: outer.k * inner.x + outer.x, y: outer.k * inner.y + outer.y };
7
+ }
8
+ /** The pan and scale that carries what is drawn through `settled` to where `live` puts it. */
9
+ static between(settled, live) {
10
+ const k = live.k / settled.k;
11
+ return { k, x: live.x - settled.x * k, y: live.y - settled.y * k };
12
+ }
4
13
  /** `projection`, then `outer` over `inner` (each a pan and scale) as one move in pixels. */
5
14
  static of(projection, inner, outer) {
6
- const k = inner.k * outer.k;
7
- const x = outer.k * inner.x + outer.x;
8
- const y = outer.k * inner.y + outer.y;
15
+ const { k, x, y } = ZoomedProjection.compose(inner, outer);
9
16
  if (k === 1 && x === 0 && y === 0)
10
17
  return projection;
11
18
  const move = geoTransform({
@@ -98,6 +98,11 @@
98
98
  legend && typeof legend === 'object' && !Array.isArray(legend) ? legend : undefined
99
99
  );
100
100
 
101
+ const offTargets = $derived(legendState.offTargets(extension));
102
+ $effect(() => {
103
+ legendState.setOff(offTargets);
104
+ });
105
+
101
106
  /** What the legend shows: the author's sections, the ones its plots read as (with `extension`), or none. */
102
107
  const legendMode = $derived(
103
108
  Array.isArray(legend)
@@ -37,6 +37,17 @@ export declare class LegendState {
37
37
  turnOn(series: string): void;
38
38
  /** Turns `series` off with `action`, or back on. */
39
39
  toggle(series: string, action: LegendDisabledAction): void;
40
+ /**
41
+ * What `extension`'s `off` and `highlight` turn off, by series name: `off`
42
+ * as listed, and every other series the plots draw for a `highlight`.
43
+ */
44
+ offTargets(extension: Pick<LegendExtension, 'off' | 'highlight'> | undefined): Map<string, LegendDisabledAction>;
45
+ /**
46
+ * Turns off exactly `targets`, turning back on what an earlier call turned
47
+ * off and this one leaves out. A series the reader turned off, or turned
48
+ * off any other way, is left alone.
49
+ */
50
+ setOff(targets: Map<string, LegendDisabledAction>): void;
40
51
  /** Whether any series is off with `'omit'` - which changes the plot's own rows, not just how they draw. */
41
52
  get omits(): boolean;
42
53
  /** `series` without those turned off with `'omit'`. */
@@ -1,5 +1,5 @@
1
1
  import { getContext, setContext, untrack } from 'svelte';
2
- import { SvelteMap } from 'svelte/reactivity';
2
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
3
3
  import { getConfiguration } from '../../configuration/config.svelte';
4
4
  import { MarkerKinds } from '../../markers/MarkerKinds';
5
5
  import { LegendChannels } from './LegendChannels';
@@ -20,6 +20,8 @@ export class LegendState {
20
20
  /** The narrowed range of the first interactive continuous section, or `null`. */
21
21
  range = $state(null);
22
22
  #sources = new SvelteMap();
23
+ /** What {@link setOff} last turned off, to turn back on what it no longer lists. */
24
+ #managed = new SvelteMap();
23
25
  /** The enclosing figure's legend, or `null` outside one. Call during component init. */
24
26
  static current() {
25
27
  return getContext(LEGEND_KEY) ?? null;
@@ -67,6 +69,56 @@ export class LegendState {
67
69
  else
68
70
  this.turnOff(series, action);
69
71
  }
72
+ /**
73
+ * What `extension`'s `off` and `highlight` turn off, by series name: `off`
74
+ * as listed, and every other series the plots draw for a `highlight`.
75
+ */
76
+ offTargets(extension) {
77
+ const targets = new SvelteMap();
78
+ if (extension?.highlight !== undefined) {
79
+ const kept = new SvelteSet([extension.highlight].flat());
80
+ for (const entry of this.entries) {
81
+ if (entry.type === 'series' && !kept.has(entry.name))
82
+ targets.set(entry.name, { mode: 'dim' });
83
+ }
84
+ }
85
+ const off = extension?.off;
86
+ const listed = Array.isArray(off)
87
+ ? off.map((name) => [name, undefined])
88
+ : Object.entries(off ?? {});
89
+ for (const [name, what] of listed) {
90
+ targets.set(name, typeof what === 'number'
91
+ ? { mode: 'dim', style: { opacity: what } }
92
+ : (what ?? { mode: 'dim' }));
93
+ }
94
+ return targets;
95
+ }
96
+ /**
97
+ * Turns off exactly `targets`, turning back on what an earlier call turned
98
+ * off and this one leaves out. A series the reader turned off, or turned
99
+ * off any other way, is left alone.
100
+ */
101
+ setOff(targets) {
102
+ untrack(() => {
103
+ const ours = (name) => {
104
+ const current = this.actionOf(name);
105
+ const set = this.#managed.get(name);
106
+ return current && set && JSON.stringify(current) === JSON.stringify(set);
107
+ };
108
+ for (const name of this.#managed.keys()) {
109
+ if (!targets.has(name) && ours(name))
110
+ this.turnOn(name);
111
+ }
112
+ for (const [name, action] of targets) {
113
+ if (this.isOff(name) && !ours(name))
114
+ continue;
115
+ if (ours(name) && JSON.stringify(this.#managed.get(name)) === JSON.stringify(action))
116
+ continue;
117
+ this.turnOff(name, action);
118
+ }
119
+ this.#managed = new SvelteMap(targets);
120
+ });
121
+ }
70
122
  /** Whether any series is off with `'omit'` - which changes the plot's own rows, not just how they draw. */
71
123
  get omits() {
72
124
  return [...this.disabled.values()].some((action) => action.mode === 'omit');
@@ -55,9 +55,12 @@ export declare class PlotMargins {
55
55
  static current(): PlotMargins | null;
56
56
  /**
57
57
  * Measures `el`'s rendered box once its readings settle - again whenever
58
- * the content box or `deps` change - and reports how far it reaches past
59
- * the content box: to `onOverflow`, and, when `grow` holds, into the room
60
- * the margins grow by. Call during component init.
58
+ * the plot's area or `deps` change - and reports how far it reaches past
59
+ * that area: to `onOverflow`, and, when `grow` holds, into the room the
60
+ * margins grow by. The area is where svelteplot draws the marks (see
61
+ * {@link follow}), not the {@link contentBox}'s estimate: an `'auto'` side
62
+ * is only its estimate until svelteplot sizes its axes, and what a label
63
+ * reaches past that is no overflow. Call during component init.
61
64
  */
62
65
  watch(el: () => SVGGraphicsElement | undefined, options: {
63
66
  grow?: () => boolean;
@@ -68,8 +71,9 @@ export declare class PlotMargins {
68
71
  reportOverflow(overflow: Margins): void;
69
72
  /**
70
73
  * Follows the margins svelteplot settles on - `plot` is its `usePlot()`,
71
- * only readable inside its `<Plot>` - and reports them to the enclosing
72
- * aligned grid cell, if any. Call during the init of a component inside it.
74
+ * only readable inside its `<Plot>` - to measure against the area they
75
+ * leave ({@link watch}), and reports them to the enclosing aligned grid
76
+ * cell, if any. Call during the init of a component inside it.
73
77
  */
74
78
  follow(plot: {
75
79
  options: Record<string, unknown>;
@@ -25,6 +25,8 @@ export class PlotMargins {
25
25
  #measured = $state.raw(Margins.ZERO);
26
26
  /** The sides the labels settled on, as last handed to `settle`. */
27
27
  #settled;
28
+ /** What svelteplot's own `<Plot>` lays the plot out by, once {@link follow} is handed it. */
29
+ #plot = $state.raw();
28
30
  constructor(inputs) {
29
31
  this.#inputs = inputs;
30
32
  setContext(PLOT_MARGINS_KEY, this);
@@ -54,14 +56,17 @@ export class PlotMargins {
54
56
  }
55
57
  /**
56
58
  * Measures `el`'s rendered box once its readings settle - again whenever
57
- * the content box or `deps` change - and reports how far it reaches past
58
- * the content box: to `onOverflow`, and, when `grow` holds, into the room
59
- * the margins grow by. Call during component init.
59
+ * the plot's area or `deps` change - and reports how far it reaches past
60
+ * that area: to `onOverflow`, and, when `grow` holds, into the room the
61
+ * margins grow by. The area is where svelteplot draws the marks (see
62
+ * {@link follow}), not the {@link contentBox}'s estimate: an `'auto'` side
63
+ * is only its estimate until svelteplot sizes its axes, and what a label
64
+ * reaches past that is no overflow. Call during component init.
60
65
  */
61
66
  watch(el, options) {
62
67
  $effect(() => {
63
68
  const element = el();
64
- const bounds = this.contentBox;
69
+ const bounds = this.#plotArea ?? this.contentBox;
65
70
  options.deps?.();
66
71
  if (!element)
67
72
  return;
@@ -113,10 +118,12 @@ export class PlotMargins {
113
118
  }
114
119
  /**
115
120
  * Follows the margins svelteplot settles on - `plot` is its `usePlot()`,
116
- * only readable inside its `<Plot>` - and reports them to the enclosing
117
- * aligned grid cell, if any. Call during the init of a component inside it.
121
+ * only readable inside its `<Plot>` - to measure against the area they
122
+ * leave ({@link watch}), and reports them to the enclosing aligned grid
123
+ * cell, if any. Call during the init of a component inside it.
118
124
  */
119
125
  follow(plot) {
126
+ this.#plot = plot;
120
127
  const cell = this.#cell;
121
128
  if (!cell)
122
129
  return;
@@ -236,6 +243,27 @@ export class PlotMargins {
236
243
  });
237
244
  /** The plot's own area, in pixels: what its margins leave inside its box. */
238
245
  contentBox = $derived.by(() => this.numeric.contentBox(this.box.width, this.box.height));
246
+ /**
247
+ * The plot's own area as svelteplot lays it out, by the margins it settled on, or `undefined` until
248
+ * the plot is followed. {@link contentBox} reads an `'auto'` side as its estimate, which is not where
249
+ * svelteplot draws the marks until its axes are sized.
250
+ */
251
+ #plotArea = $derived.by(() => {
252
+ const options = this.#plot?.options;
253
+ if (!options)
254
+ return undefined;
255
+ const sides = [
256
+ options.marginTop,
257
+ options.marginRight,
258
+ options.marginBottom,
259
+ options.marginLeft
260
+ ];
261
+ if (sides.some((side) => typeof side !== 'number' || !Number.isFinite(side)))
262
+ return undefined;
263
+ const [top, right, bottom, left] = sides;
264
+ const { width, height } = this.#inputs.size();
265
+ return new Margins(top, right, bottom, left).contentBox(width, height);
266
+ });
239
267
  /**
240
268
  * Where something of `size` sits next to `point`, on its preferred side
241
269
  * per axis, without leaving the plot's box - see {@link Box.placeNear}.
@@ -17,8 +17,11 @@ export declare function resolveSegmentsForSeries<TStyle>(segments: SegmentsConfi
17
17
  export declare function matchesSegmentX<TStyle>(xVal: AxisValue, seg: Segment<TStyle>): boolean;
18
18
  /**
19
19
  * Warns about obvious configuration mistakes: multiple catch-all segments
20
- * (no `areas`) in the same series list. Since catch-alls are evaluated last
21
- * and in order, only the first one can ever match; later ones are dead code.
20
+ * (no `areas`) in the same series list that reach the same facet panels.
21
+ * Since catch-alls are evaluated last and in order, only the first one can
22
+ * ever match; later ones are dead code. A catch-all that names a `facet`
23
+ * does not compete with one for another panel, nor with the unscoped one,
24
+ * which it overrides in its own panels.
22
25
  */
23
26
  export declare function validateSegments<TStyle>(segments: SegmentsConfig<TStyle>): void;
24
27
  export declare function buildGroupedSeries<S extends Series<any>, TStyle = LineSegmentStyle>(seriesList: S[], segments?: SegmentsConfig<TStyle>, connect?: boolean): VisualGroup<S, TStyle>[];
@@ -49,12 +49,22 @@ function areaMatchesXY(xVal, yVal, area) {
49
49
  /* eslint-enable @typescript-eslint/no-explicit-any */
50
50
  /**
51
51
  * Warns about obvious configuration mistakes: multiple catch-all segments
52
- * (no `areas`) in the same series list. Since catch-alls are evaluated last
53
- * and in order, only the first one can ever match; later ones are dead code.
52
+ * (no `areas`) in the same series list that reach the same facet panels.
53
+ * Since catch-alls are evaluated last and in order, only the first one can
54
+ * ever match; later ones are dead code. A catch-all that names a `facet`
55
+ * does not compete with one for another panel, nor with the unscoped one,
56
+ * which it overrides in its own panels.
54
57
  */
55
58
  export function validateSegments(segments) {
56
59
  const check = (label, segs) => {
57
- const catchAlls = segs.filter((s) => !s.areas || s.areas.length === 0).length;
60
+ const perScope = new Map();
61
+ for (const seg of segs) {
62
+ if (seg.areas && seg.areas.length > 0)
63
+ continue;
64
+ const scope = JSON.stringify(seg.facet ?? null);
65
+ perScope.set(scope, (perScope.get(scope) ?? 0) + 1);
66
+ }
67
+ const catchAlls = Math.max(0, ...perScope.values());
58
68
  if (catchAlls > 1) {
59
69
  console.warn(`segments ${label}: ${catchAlls} entries have no areas - ` +
60
70
  `only the first catch-all's style will be used.`);
@@ -292,6 +292,20 @@ export type ColorOverride = Omit<ContinuousLegendSection, 'type' | 'min' | 'max'
292
292
  * ramp `interactive`, and only its first ramp is.
293
293
  */
294
294
  export type LegendExtension = {
295
+ /**
296
+ * Series to turn off from code, as if the reader had clicked them: a list
297
+ * of names (each dimmed, to `legend.disabledOpacity`), or a name → what
298
+ * turning it off does map - an opacity to dim to, or a full
299
+ * {@link LegendDisabledAction} (`dim`, `hide` or `omit`). It follows the
300
+ * value it is given: a series left out is turned back on. One the reader
301
+ * turned off themselves is left as it is.
302
+ */
303
+ off?: string[] | Record<string, number | LegendDisabledAction>;
304
+ /**
305
+ * The series to keep on: every other series is dimmed, the inverse of
306
+ * listing the rest in `off`. An entry of `off` for a series wins.
307
+ */
308
+ highlight?: string | string[];
295
309
  items?: LegendItem[];
296
310
  sections?: LegendSection[];
297
311
  series?: Omit<DiscreteLegendSection, 'type' | 'items'>;
package/docs/legend.md CHANGED
@@ -101,6 +101,20 @@ legend={{
101
101
 
102
102
  Each key is the name of the channel (the plot's own prop: `r` for the size section) and adjusts the section that channel draws: any section field except what the plots compute (items, ranges, domains). An entry with `match` adjusts only the section whose title matches; a list of entries adjusts several.
103
103
 
104
+ ### Turning series off from code
105
+
106
+ `off` turns series off as if the reader had clicked them, without a click: a list of names (each dimmed, to `legend.disabledOpacity`), or a name → opacity or name → action map, where an action is the same `{ mode: 'dim' | 'hide' | 'omit' }` a toggle does. `highlight` is its inverse: it keeps the series it names on and dims all the others. An `off` entry for a series wins over `highlight`.
107
+
108
+ ```ts
109
+ legend={{ off: ['2020'] }} // dim
110
+ legend={{ off: { '2020': 0.1, '2021': { mode: 'omit' } } }} // dim to 0.1, drop from the plot
111
+ legend={{ highlight: 'Argentina' }} // everything else dims
112
+ // A facet plot: dim a series only when no panel shows it.
113
+ legend={{ off: ages.filter((age) => facets.every((f) => !visible[f].includes(age))) }}
114
+ ```
115
+
116
+ They follow the value they are given: a series left out is turned back on, and one the reader turned off themselves is left as it is.
117
+
104
118
  ## Explicit sections
105
119
 
106
120
  A list replaces the automatic legend with exactly those sections:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",