@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 +10 -0
- package/dist/layout/figure/Figure.svelte +5 -0
- package/dist/layout/legend/LegendState.svelte.d.ts +11 -0
- package/dist/layout/legend/LegendState.svelte.js +53 -1
- package/dist/layout/margins/PlotMargins.svelte.d.ts +9 -5
- package/dist/layout/margins/PlotMargins.svelte.js +34 -6
- package/dist/types/layout/legend.d.ts +14 -0
- package/docs/legend.md +14 -0
- package/package.json +1 -1
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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>` -
|
|
72
|
-
*
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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>` -
|
|
117
|
-
*
|
|
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:
|