@fundar/data-chart-telling 1.3.0 → 1.4.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,16 @@
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.4.0] - 2026-10-30
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+
11
+ ### Fixed
12
+
13
+ - 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.
14
+
5
15
  ## [1.3.0] - 2026-10-30
6
16
 
7
17
  ### Added
@@ -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}.
@@ -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.4.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",