@fundar/data-chart-telling 1.2.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -1
  3. package/dist/configuration/config.svelte.js +12 -0
  4. package/dist/layout/facet/scope.d.ts +40 -0
  5. package/dist/layout/facet/scope.js +104 -0
  6. package/dist/layout/figure/Figure.svelte +5 -0
  7. package/dist/layout/legend/DiscreteSymbolIcon.svelte +38 -14
  8. package/dist/layout/legend/LegendState.svelte.d.ts +11 -0
  9. package/dist/layout/legend/LegendState.svelte.js +56 -6
  10. package/dist/layout/margins/PlotMargins.svelte.d.ts +9 -5
  11. package/dist/layout/margins/PlotMargins.svelte.js +65 -6
  12. package/dist/layout/plot/Plot.svelte +22 -2
  13. package/dist/layout/scales/ScalesLayout.svelte +21 -0
  14. package/dist/markers/delta/Delta.d.ts +6 -1
  15. package/dist/markers/delta/Delta.js +15 -0
  16. package/dist/markers/delta/DeltaMarker.svelte +9 -87
  17. package/dist/markers/delta/DeltaPoint.svelte +97 -0
  18. package/dist/markers/delta/DeltaPoint.svelte.d.ts +29 -0
  19. package/dist/markers/rule/RuleMarker.svelte +5 -2
  20. package/dist/phases/facet/FacetPhase.d.ts +2 -1
  21. package/dist/phases/facet/FacetPhase.js +19 -4
  22. package/dist/phases/statistics/StatisticsPhase.js +9 -1
  23. package/dist/plots/utils/segments.d.ts +4 -1
  24. package/dist/plots/utils/segments.js +19 -6
  25. package/dist/types/configuration/styling.d.ts +8 -0
  26. package/dist/types/layout/facet.d.ts +18 -0
  27. package/dist/types/layout/legend.d.ts +16 -0
  28. package/dist/types/layout/plot.d.ts +2 -0
  29. package/dist/types/markers/axis/delta.d.ts +15 -2
  30. package/dist/types/markers/axis/rule.d.ts +9 -2
  31. package/dist/types/phases/facet/phase.d.ts +4 -0
  32. package/dist/types/plots/props.d.ts +6 -3
  33. package/dist/types/plots/segments/config.d.ts +4 -2
  34. package/docs/facets.md +47 -0
  35. package/docs/legend.md +14 -0
  36. package/docs/markers.md +54 -26
  37. package/docs/styles-and-segments.md +1 -0
  38. package/docs/theming.md +1 -1
  39. package/examples/FacetsScoped.svelte +26 -0
  40. package/llms-full.txt +5 -3
  41. package/llms.txt +1 -0
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+
15
+ ## [1.3.0] - 2026-10-30
16
+
17
+ ### Added
18
+
19
+ - `legend.icon` in the configuration sets the icon a discrete legend item draws for its series: `rounded` softens its corners, and `marks` picks the shape by how the series draws (`line`, `dot`, `area`, `bar`, `cell`, `rect` or `geo`), unless the series sets its own `symbol`. A legend item can name its `mark` to choose from it.
20
+ - A `rule` marker takes a list as its `value`, and a `delta` marker a list as its `at`: one line, or one delta, at each position, all with the same style, instead of one marker for each. A delta's `line`, `text` and `dot` can be functions of the position, to style each one on its own.
21
+ - `facet` on a marker or a segment limits it to some facet panels, on every plot kind: a `by` value, a list of them, or `{ row, col }` in a `rows`/`cols` grid. Without it a marker or a segment is drawn in every panel, as before, and a plot with no facet leaves out whatever names one.
22
+
23
+ ### Fixed
24
+
25
+ - Automatic margins reserve what a plot needs without taking its drawing area: the label of the top y tick is no longer cut off, since an `'auto'` top margin now leaves room for half its height, and margins that grow to fit labels stop growing once they would leave the plot less than a quarter of its box, which kept svelteplot from drawing marks with negative sizes on small plots.
26
+
5
27
  ## [1.2.0] - 2026-10-24
6
28
 
7
29
  ### Added
@@ -28,6 +50,7 @@ All notable changes to this project are documented here. The format follows [Kee
28
50
  ### Changed
29
51
 
30
52
  - The default palette is Tableau 10, and the default continuous ramp, the timeline and player accents and the connector colors follow it.
53
+ - Plots draw faster: a plot in a facet, a grid or a composer no longer remounts its svelteplot chart each time the margins its labels need settle after mount. `pnpm test:perf` checks that every plot kind draws all the panels of a facet grid within a time budget; it runs before a push but not in CI, where the speed of the machine makes a time limit flaky.
31
54
 
32
55
  ### Fixed
33
56
 
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  [![GitHub release (latest by date)](https://img.shields.io/github/v/release/datos-Fundar/data-chart-telling?label=Latest%20Release&display_name=tag&logo=github&logoColor=white)](https://github.com/datos-Fundar/data-chart-telling/releases/latest)
11
11
  ![GitHub License](https://img.shields.io/github/license/datos-Fundar/data-chart-telling?label=License&logo=github&logoColor=white)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/datos-Fundar/data-chart-telling/ci.yml?branch=main&label=CI&logo=githubactions&logoColor=white)](https://github.com/datos-Fundar/data-chart-telling/actions/workflows/ci.yml)
13
+ [![Storybook](https://img.shields.io/badge/Storybook-live-FF4785?logo=storybook&logoColor=white)](https://datos-fundar.github.io/data-chart-telling/)
13
14
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/datos-Fundar/data-chart-telling/codeql.yml?branch=main&label=CodeQL&logo=github&logoColor=white)](https://github.com/datos-Fundar/data-chart-telling/actions/workflows/codeql.yml)
14
15
 
15
16
  ## Introduction
@@ -150,7 +151,7 @@ Start with the concepts; the rest is organised by what you want a plot to do.
150
151
  | [Architecture](docs/architecture.md) | How a plot is assembled, for contributors |
151
152
  | [Development](docs/development.md) | Building, testing, Storybook and publishing |
152
153
 
153
- Working examples live in [examples/](examples/); the Storybook (`pnpm storybook`) shows every option live.
154
+ Working examples live in [examples/](examples/); the [online Storybook](https://datos-fundar.github.io/data-chart-telling/) (or `pnpm storybook` locally) shows every option live.
154
155
 
155
156
  ## For LLMs and coding agents
156
157
 
@@ -92,6 +92,18 @@ export const DEFAULT_CONFIG = {
92
92
  titleWeight: 500,
93
93
  gap: '0.5rem 2rem',
94
94
  swatchSize: '0.9rem',
95
+ icon: {
96
+ rounded: false,
97
+ marks: {
98
+ line: 'line',
99
+ dot: 'circle',
100
+ area: 'square',
101
+ bar: 'square',
102
+ cell: 'square',
103
+ rect: 'square',
104
+ geo: 'square'
105
+ }
106
+ },
95
107
  disabledOpacity: 0.12,
96
108
  pretty: true,
97
109
  seriesTitle: { enabled: true, text: 'Series' },
@@ -0,0 +1,40 @@
1
+ import type { FacetTarget } from '../../types/layout/facet';
2
+ import type { SlicePlace } from '../../types/layout/plot';
3
+ import type { FacetPanel } from '../../types/phases';
4
+ /** Where a facet panel sits, as a segment's or a marker's `facet` names it. */
5
+ type PanelPlace = SlicePlace['facet'];
6
+ /**
7
+ * Limiting a plot's segments and markers to some facet panels: which panels a `facet` names, and each
8
+ * panel's share of a list of them. A plot with no facet has no panel, so what names one is left out.
9
+ */
10
+ export declare class FacetScope {
11
+ /** Where a panel the "facet" phase laid out sits. */
12
+ static placeOf(panel: FacetPanel): NonNullable<PanelPlace>;
13
+ /**
14
+ * Whether something limited to `target` applies at the facet panel `place`. Without a target it
15
+ * applies everywhere; with one, only to the panels it names, and to no plot that has no facet.
16
+ */
17
+ static targets(target: FacetTarget | undefined, place: PanelPlace): boolean;
18
+ /**
19
+ * `items` as the facet panel `place` sees them: those that name no facet, or name this one, with
20
+ * their `facet` taken off. The same list comes back when none names a facet.
21
+ */
22
+ static scope<T extends object>(items: T[] | undefined, place: PanelPlace): T[] | undefined;
23
+ /** A `segments` config as the facet panel `place` sees it: each series' segments, {@link FacetScope.scope}d. */
24
+ static scopeSegments<T extends object>(segments: Record<string, T[]> | undefined, place: PanelPlace): Record<string, T[]> | undefined;
25
+ /**
26
+ * `segments` for a plot whose series are split by facet panel: each segment that names a facet moves
27
+ * under the key of its series in every panel it names - the id of that series' part there, as
28
+ * {@link FacetPanels.buildSeriesId} writes it, or `default` for the panel's series without their own -
29
+ * and loses its `facet`. Segments that name none stay where they were. The same config comes back
30
+ * when none names a facet.
31
+ */
32
+ static distributeSegments<T extends object>(segments: Record<string, T[]> | undefined, panels: FacetPanel[]): Record<string, T[]> | undefined;
33
+ /**
34
+ * `markers` for a plot split by facet panel: each marker that names a facet becomes one copy per
35
+ * panel it names, tagged with that panel ({@link PANEL_KEY}) and without its `facet`, in the marker's
36
+ * own place in the list. Markers that name none stay as they are, for every panel.
37
+ */
38
+ static distributeMarkers<T extends object>(markers: T[] | undefined, panels: FacetPanel[]): T[] | undefined;
39
+ }
40
+ export {};
@@ -0,0 +1,104 @@
1
+ import { FacetPanels, PANEL_KEY } from '../../phases/facet/FacetPanels';
2
+ /**
3
+ * Limiting a plot's segments and markers to some facet panels: which panels a `facet` names, and each
4
+ * panel's share of a list of them. A plot with no facet has no panel, so what names one is left out.
5
+ */
6
+ export class FacetScope {
7
+ /** Where a panel the "facet" phase laid out sits. */
8
+ static placeOf(panel) {
9
+ return {
10
+ facetValue: panel.label,
11
+ ...(panel.row !== undefined ? { row: panel.row } : {}),
12
+ ...(panel.col !== undefined ? { col: panel.col } : {})
13
+ };
14
+ }
15
+ /**
16
+ * Whether something limited to `target` applies at the facet panel `place`. Without a target it
17
+ * applies everywhere; with one, only to the panels it names, and to no plot that has no facet.
18
+ */
19
+ static targets(target, place) {
20
+ if (target === undefined)
21
+ return true;
22
+ if (!place)
23
+ return false;
24
+ const names = (value) => (Array.isArray(value) ? value : [value]).map(String);
25
+ if (typeof target === 'object' && !Array.isArray(target)) {
26
+ const matches = (wanted, actual) => wanted === undefined || (actual !== undefined && names(wanted).includes(actual));
27
+ return matches(target.row, place.row) && matches(target.col, place.col);
28
+ }
29
+ return names(target).includes(place.facetValue);
30
+ }
31
+ /**
32
+ * `items` as the facet panel `place` sees them: those that name no facet, or name this one, with
33
+ * their `facet` taken off. The same list comes back when none names a facet.
34
+ */
35
+ static scope(items, place) {
36
+ const facetOf = (item) => item.facet;
37
+ if (!items || items.every((item) => facetOf(item) === undefined))
38
+ return items;
39
+ return items
40
+ .filter((item) => FacetScope.targets(facetOf(item), place))
41
+ .map((item) => {
42
+ const rest = { ...item };
43
+ delete rest.facet;
44
+ return rest;
45
+ });
46
+ }
47
+ /** A `segments` config as the facet panel `place` sees it: each series' segments, {@link FacetScope.scope}d. */
48
+ static scopeSegments(segments, place) {
49
+ if (!segments)
50
+ return segments;
51
+ const scoped = Object.entries(segments).map(([series, list]) => [series, FacetScope.scope(list, place) ?? list]);
52
+ if (scoped.every(([series, list]) => list === segments[series]))
53
+ return segments;
54
+ return Object.fromEntries(scoped);
55
+ }
56
+ /**
57
+ * `segments` for a plot whose series are split by facet panel: each segment that names a facet moves
58
+ * under the key of its series in every panel it names - the id of that series' part there, as
59
+ * {@link FacetPanels.buildSeriesId} writes it, or `default` for the panel's series without their own -
60
+ * and loses its `facet`. Segments that name none stay where they were. The same config comes back
61
+ * when none names a facet.
62
+ */
63
+ static distributeSegments(segments, panels) {
64
+ if (!segments)
65
+ return segments;
66
+ const named = Object.values(segments).some((list) => list.some((item) => item.facet !== undefined));
67
+ if (!named)
68
+ return segments;
69
+ const distributed = {};
70
+ for (const [series, list] of Object.entries(segments)) {
71
+ for (const item of list) {
72
+ const { facet, ...rest } = item;
73
+ if (facet === undefined) {
74
+ (distributed[series] ??= []).push(item);
75
+ continue;
76
+ }
77
+ for (const panel of panels) {
78
+ if (!FacetScope.targets(facet, FacetScope.placeOf(panel)))
79
+ continue;
80
+ (distributed[FacetPanels.buildSeriesId(panel.key, series)] ??= []).push(rest);
81
+ }
82
+ }
83
+ }
84
+ return distributed;
85
+ }
86
+ /**
87
+ * `markers` for a plot split by facet panel: each marker that names a facet becomes one copy per
88
+ * panel it names, tagged with that panel ({@link PANEL_KEY}) and without its `facet`, in the marker's
89
+ * own place in the list. Markers that name none stay as they are, for every panel.
90
+ */
91
+ static distributeMarkers(markers, panels) {
92
+ if (!markers || markers.every((marker) => marker.facet === undefined)) {
93
+ return markers;
94
+ }
95
+ return markers.flatMap((marker) => {
96
+ const { facet, ...rest } = marker;
97
+ if (facet === undefined)
98
+ return [marker];
99
+ return panels
100
+ .filter((panel) => FacetScope.targets(facet, FacetScope.placeOf(panel)))
101
+ .map((panel) => ({ ...rest, [PANEL_KEY]: panel.key }));
102
+ });
103
+ }
104
+ }
@@ -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)
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
+ import { getConfiguration } from '../../configuration/config.svelte';
2
3
  import type { LegendItem } from '../../types/layout/legend';
3
- import type { LineStyle } from '../../types/plots/constants';
4
+ import type { LineStyle, SymbolType } from '../../types/plots/constants';
4
5
  import { SymbolPath } from '../../utils/SymbolPath';
5
6
 
6
7
  /**
@@ -8,16 +9,35 @@
8
9
  * (`DiscreteSwatchItem` and `DiscreteBadgeItem`) so the two stay in sync.
9
10
  * `color` is caller-supplied rather than read off `item` - the swatch look
10
11
  * fills it with the series color, the badge look with its own label color.
11
- * A shape draws as the plot's dot does, fitted to the icon.
12
+ * The shape is the item's own `symbol`, or the one `legend.icon.marks` gives
13
+ * the way it draws. A shape draws as the plot's dot does, fitted to the icon;
14
+ * `legend.icon.rounded` softens its corners.
12
15
  */
13
16
  let { item, color }: { item: LegendItem; color: string } = $props();
14
17
 
18
+ const cfg = $derived(getConfiguration());
19
+ const icon = $derived(cfg.legend.icon);
20
+ const shape = $derived<SymbolType>(
21
+ item.symbol ?? (item.mark ? icon.marks[item.mark] : undefined) ?? 'square'
22
+ );
23
+ const rounded = $derived(icon.rounded);
15
24
  const filled = $derived(item.filled ?? true);
16
- const paint = $derived(
17
- filled
18
- ? { fill: color }
19
- : { fill: 'none', stroke: color, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' }
25
+ // A rounded filled shape grows a same-colored stroke with round joins, so it is drawn smaller by that much.
26
+ const paint = $derived<Record<string, string | number>>(
27
+ !filled
28
+ ? {
29
+ fill: 'none',
30
+ stroke: color,
31
+ 'stroke-width': 1.5,
32
+ ...(rounded ? { 'stroke-linejoin': 'round' } : {}),
33
+ 'vector-effect': 'non-scaling-stroke'
34
+ }
35
+ : rounded
36
+ ? { fill: color, stroke: color, 'stroke-width': 1.5, 'stroke-linejoin': 'round' }
37
+ : { fill: color }
20
38
  );
39
+ const size = $derived(filled ? (rounded ? 6.5 : 8) : 7);
40
+ const offset = $derived(!filled ? 1.5 : rounded ? 1.75 : 1);
21
41
 
22
42
  function dashArray(style?: LineStyle): string | undefined {
23
43
  switch (style) {
@@ -34,13 +54,7 @@
34
54
  </script>
35
55
 
36
56
  <svg class="dct-discrete__swatch" viewBox="0 0 15 10">
37
- {#if item.symbol && item.symbol !== 'line'}
38
- <path
39
- d={SymbolPath.fitted(item.symbol, filled ? 8 : 7)}
40
- transform={filled ? 'translate(1,1)' : 'translate(1.5,1.5)'}
41
- {...paint}
42
- />
43
- {:else if item.symbol === 'line'}
57
+ {#if shape === 'line'}
44
58
  <line
45
59
  x1="2"
46
60
  y1="5"
@@ -51,8 +65,18 @@
51
65
  stroke-linecap="round"
52
66
  stroke-dasharray={dashArray(item.lineStyle)}
53
67
  />
54
- {:else}
68
+ {:else if rounded && filled && shape === 'square'}
69
+ <rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
70
+ {:else if rounded && filled && shape === 'triangle'}
71
+ <path
72
+ d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
73
+ fill={color}
74
+ transform="translate(0,-1)"
75
+ />
76
+ {:else if shape === 'square' && filled && !rounded}
55
77
  <rect x="1" y="1" width="8" height="8" fill={color} />
78
+ {:else}
79
+ <path d={SymbolPath.fitted(shape, size)} transform="translate({offset},{offset})" {...paint} />
56
80
  {/if}
57
81
  </svg>
58
82
 
@@ -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');
@@ -340,11 +392,9 @@ export class LegendState {
340
392
  ...(entry.label ? { label: entry.label } : {}),
341
393
  color: entry.color,
342
394
  ...(entry.filled === false ? { filled: false } : {}),
343
- ...(entry.symbol || entry.mark === 'dot'
344
- ? { symbol: entry.symbol ?? 'circle' }
345
- : entry.mark === 'line'
346
- ? { symbol: 'line', lineStyle: entry.lineStyle ?? 'solid' }
347
- : {})
395
+ ...(entry.mark ? { mark: entry.mark } : {}),
396
+ ...(entry.symbol ? { symbol: entry.symbol } : {}),
397
+ ...(entry.mark === 'line' ? { lineStyle: entry.lineStyle ?? 'solid' } : {})
348
398
  };
349
399
  }
350
400
  }
@@ -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;
@@ -148,8 +155,39 @@ export class PlotMargins {
148
155
  if (held !== undefined || grown !== undefined)
149
156
  out[side] = Math.max(held ?? 0, grown ?? 0);
150
157
  }
158
+ for (const [a, b, extent] of [
159
+ ['left', 'right', width],
160
+ ['top', 'bottom', height]
161
+ ]) {
162
+ PlotMargins.#keepContent(out, [a, b], extent, author, estimate);
163
+ }
151
164
  return out;
152
165
  });
166
+ /** The share of a box's extent its margins always leave to the plot's own area. */
167
+ static #MIN_CONTENT = 0.25;
168
+ /**
169
+ * Cuts the growth of two opposite sides of `out`, in proportion, until they
170
+ * leave the plot's area its minimum share of `extent` - a negative area makes
171
+ * svelteplot draw marks with negative sizes. A side never goes below the
172
+ * estimate, and a side the author fixed is left alone.
173
+ */
174
+ static #keepContent(out, sides, extent, author, estimate) {
175
+ const fixed = (side) => {
176
+ const own = author?.[side];
177
+ return typeof own === 'number' ? own : estimate[side];
178
+ };
179
+ const used = (side) => out[side] ?? fixed(side);
180
+ const over = used(sides[0]) + used(sides[1]) - extent * (1 - PlotMargins.#MIN_CONTENT);
181
+ const room = sides.map((side) => out[side] === undefined ? 0 : Math.max(0, out[side] - estimate[side]));
182
+ const total = room[0] + room[1];
183
+ if (over <= 0 || total <= 0)
184
+ return;
185
+ const cut = Math.min(over, total);
186
+ sides.forEach((side, i) => {
187
+ if (out[side] !== undefined)
188
+ out[side] -= (cut * room[i]) / total;
189
+ });
190
+ }
153
191
  /** The author's margins, with the room labels need. */
154
192
  #labels = $derived.by(() => {
155
193
  const author = this.#inputs.author();
@@ -205,6 +243,27 @@ export class PlotMargins {
205
243
  });
206
244
  /** The plot's own area, in pixels: what its margins leave inside its box. */
207
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
+ });
208
267
  /**
209
268
  * Where something of `size` sits next to `point`, on its preferred side
210
269
  * per axis, without leaving the plot's box - see {@link Box.placeNear}.
@@ -18,6 +18,7 @@
18
18
  import { merge } from '../../utils/merge';
19
19
  import { Pipeline } from '../../phases/Pipeline';
20
20
  import { Slice } from './Slice';
21
+ import { FacetScope } from '../facet/scope';
21
22
  import type { TooltipProp } from '../../types/layout/tooltip';
22
23
  import type { MarginConfig, PlotChromeConfig } from '../../types/plots/props';
23
24
  import type { PlotCellContext, PlotCellSnippet, PlotCoreBase } from '../../types/layout/plot';
@@ -153,6 +154,24 @@
153
154
  return rest;
154
155
  }
155
156
 
157
+ /**
158
+ * The props of the core that draws the slice `ctx`: the kind's `slice`, or the plot's own, with the
159
+ * `markers` and `segments` that name a facet left to the panels they name. The data phases already
160
+ * handed them to their panels; this is for the cores that read them from their own props.
161
+ */
162
+ function coreProps(ctx: PlotCellContext<TRow, TScales>) {
163
+ const props = (slice ?? buildSliceProps)(ctx) as Record<string, unknown>;
164
+ const { markers, segments } = props as {
165
+ markers?: object[];
166
+ segments?: Record<string, object[]>;
167
+ };
168
+ return {
169
+ ...props,
170
+ ...(markers ? { markers: FacetScope.scope(markers, ctx.facet) } : {}),
171
+ ...(segments ? { segments: FacetScope.scopeSegments(segments, ctx.facet) } : {})
172
+ };
173
+ }
174
+
156
175
  function buildSliceProps(ctx: PlotCellContext<TRow, TScales>) {
157
176
  return {
158
177
  ...core,
@@ -172,9 +191,9 @@
172
191
  {#if cell}
173
192
  {@render cell(ctx)}
174
193
  {:else if Core && sliced}
175
- <Core {...(slice ?? buildSliceProps)(ctx)} {pipeline} />
194
+ <Core {...coreProps(ctx)} {pipeline} />
176
195
  {:else if Core}
177
- <Core {...withoutMargins((slice ?? buildSliceProps)(ctx))} bind:margins {pipeline} />
196
+ <Core {...withoutMargins(coreProps(ctx))} bind:margins {pipeline} />
178
197
  {/if}
179
198
  {/snippet}
180
199
 
@@ -194,6 +213,7 @@
194
213
  panel: panels
195
214
  ? FacetPanels.buildPanelState(panels, slicing.panelKey(place) ?? '')
196
215
  : undefined,
216
+ facet: place,
197
217
  keep: slicing.keep({ time, facet: place })
198
218
  })}
199
219
  </HoverScope>
@@ -243,6 +243,27 @@
243
243
  $autoMarginLeft.delete(marginId);
244
244
  });
245
245
 
246
+ // The top y tick's label is centered on the plot's top edge, so it reaches
247
+ // half its height above it - more than svelteplot's own 5px `'auto'` top margin.
248
+ const { autoMarginTop } = getContext<{ autoMarginTop: Writable<Map<string, number>> }>(
249
+ 'svelteplot/autoMargins'
250
+ );
251
+ const tickMarginId = `dct-y-tick-${Math.random().toString(36).slice(2)}`;
252
+ $effect(() => {
253
+ const wanted =
254
+ (scales?.y?.axisDisplay ?? 'visible') === 'visible'
255
+ ? Math.ceil(cfg.axis.fontSize / 2) + 2
256
+ : undefined;
257
+ untrack(() => {
258
+ if (wanted === undefined) $autoMarginTop.delete(tickMarginId);
259
+ else if ($autoMarginTop.get(tickMarginId) !== wanted)
260
+ $autoMarginTop.set(tickMarginId, wanted);
261
+ });
262
+ });
263
+ $effect(() => () => {
264
+ $autoMarginTop.delete(tickMarginId);
265
+ });
266
+
246
267
  // An x label centred on the domain's end reaches past the plot's right side; an `'auto'` right margin takes that much room.
247
268
  let xAxisEl: SVGGElement | undefined = $state();
248
269
  const { autoMarginRight } = getContext<{ autoMarginRight: Writable<Map<string, number>> }>(