@fundar/data-chart-telling 1.2.0 → 1.3.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 (38) hide show
  1. package/CHANGELOG.md +13 -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/legend/DiscreteSymbolIcon.svelte +38 -14
  7. package/dist/layout/legend/LegendState.svelte.js +3 -5
  8. package/dist/layout/margins/PlotMargins.svelte.js +31 -0
  9. package/dist/layout/plot/Plot.svelte +22 -2
  10. package/dist/layout/scales/ScalesLayout.svelte +21 -0
  11. package/dist/markers/delta/Delta.d.ts +6 -1
  12. package/dist/markers/delta/Delta.js +15 -0
  13. package/dist/markers/delta/DeltaMarker.svelte +9 -87
  14. package/dist/markers/delta/DeltaPoint.svelte +97 -0
  15. package/dist/markers/delta/DeltaPoint.svelte.d.ts +29 -0
  16. package/dist/markers/rule/RuleMarker.svelte +5 -2
  17. package/dist/phases/facet/FacetPhase.d.ts +2 -1
  18. package/dist/phases/facet/FacetPhase.js +19 -4
  19. package/dist/phases/statistics/StatisticsPhase.js +9 -1
  20. package/dist/plots/utils/segments.d.ts +4 -1
  21. package/dist/plots/utils/segments.js +19 -6
  22. package/dist/types/configuration/styling.d.ts +8 -0
  23. package/dist/types/layout/facet.d.ts +18 -0
  24. package/dist/types/layout/legend.d.ts +2 -0
  25. package/dist/types/layout/plot.d.ts +2 -0
  26. package/dist/types/markers/axis/delta.d.ts +15 -2
  27. package/dist/types/markers/axis/rule.d.ts +9 -2
  28. package/dist/types/phases/facet/phase.d.ts +4 -0
  29. package/dist/types/plots/props.d.ts +6 -3
  30. package/dist/types/plots/segments/config.d.ts +4 -2
  31. package/docs/facets.md +47 -0
  32. package/docs/markers.md +54 -26
  33. package/docs/styles-and-segments.md +1 -0
  34. package/docs/theming.md +1 -1
  35. package/examples/FacetsScoped.svelte +26 -0
  36. package/llms-full.txt +5 -3
  37. package/llms.txt +1 -0
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
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.3.0] - 2026-10-30
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+ - 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.
11
+ - `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.
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+
5
17
  ## [1.2.0] - 2026-10-24
6
18
 
7
19
  ### Added
@@ -28,6 +40,7 @@ All notable changes to this project are documented here. The format follows [Kee
28
40
  ### Changed
29
41
 
30
42
  - The default palette is Tableau 10, and the default continuous ramp, the timeline and player accents and the connector colors follow it.
43
+ - 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
44
 
32
45
  ### Fixed
33
46
 
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
+ }
@@ -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
 
@@ -340,11 +340,9 @@ export class LegendState {
340
340
  ...(entry.label ? { label: entry.label } : {}),
341
341
  color: entry.color,
342
342
  ...(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
- : {})
343
+ ...(entry.mark ? { mark: entry.mark } : {}),
344
+ ...(entry.symbol ? { symbol: entry.symbol } : {}),
345
+ ...(entry.mark === 'line' ? { lineStyle: entry.lineStyle ?? 'solid' } : {})
348
346
  };
349
347
  }
350
348
  }
@@ -148,8 +148,39 @@ export class PlotMargins {
148
148
  if (held !== undefined || grown !== undefined)
149
149
  out[side] = Math.max(held ?? 0, grown ?? 0);
150
150
  }
151
+ for (const [a, b, extent] of [
152
+ ['left', 'right', width],
153
+ ['top', 'bottom', height]
154
+ ]) {
155
+ PlotMargins.#keepContent(out, [a, b], extent, author, estimate);
156
+ }
151
157
  return out;
152
158
  });
159
+ /** The share of a box's extent its margins always leave to the plot's own area. */
160
+ static #MIN_CONTENT = 0.25;
161
+ /**
162
+ * Cuts the growth of two opposite sides of `out`, in proportion, until they
163
+ * leave the plot's area its minimum share of `extent` - a negative area makes
164
+ * svelteplot draw marks with negative sizes. A side never goes below the
165
+ * estimate, and a side the author fixed is left alone.
166
+ */
167
+ static #keepContent(out, sides, extent, author, estimate) {
168
+ const fixed = (side) => {
169
+ const own = author?.[side];
170
+ return typeof own === 'number' ? own : estimate[side];
171
+ };
172
+ const used = (side) => out[side] ?? fixed(side);
173
+ const over = used(sides[0]) + used(sides[1]) - extent * (1 - PlotMargins.#MIN_CONTENT);
174
+ const room = sides.map((side) => out[side] === undefined ? 0 : Math.max(0, out[side] - estimate[side]));
175
+ const total = room[0] + room[1];
176
+ if (over <= 0 || total <= 0)
177
+ return;
178
+ const cut = Math.min(over, total);
179
+ sides.forEach((side, i) => {
180
+ if (out[side] !== undefined)
181
+ out[side] -= (cut * room[i]) / total;
182
+ });
183
+ }
153
184
  /** The author's margins, with the room labels need. */
154
185
  #labels = $derived.by(() => {
155
186
  const author = this.#inputs.author();
@@ -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>> }>(
@@ -1,5 +1,5 @@
1
1
  import type { Series } from '../../types/plots/data/common';
2
- import type { DeltaConfig } from '../../types/markers/axis/delta';
2
+ import type { DeltaConfig, DeltaMarkerConfig, DeltaPointConfig } from '../../types/markers/axis/delta';
3
3
  /** A delta, measured: where it sits (`at`), its two ends and what lies between. */
4
4
  export type ResolvedDelta = {
5
5
  cfg: DeltaConfig;
@@ -16,6 +16,11 @@ export type ResolvedDelta = {
16
16
  */
17
17
  export declare class Delta {
18
18
  #private;
19
+ /**
20
+ * The deltas a marker draws: one for its `at`, or one for each position of a list, each with the
21
+ * `line`, `text` and `dot` its style functions give for that position.
22
+ */
23
+ static points(marker: DeltaMarkerConfig): DeltaPointConfig[];
19
24
  /** The delta `config` asks for over `allSeries`, or `undefined` while an end has no value there. */
20
25
  static build<T extends Record<string, unknown>>(axis: 'x' | 'y', allSeries: Series<T>[], config: DeltaConfig): ResolvedDelta | undefined;
21
26
  }
@@ -5,6 +5,21 @@ import { resolveAccessor } from '../../utils/accessors';
5
5
  * it crosses that position, interpolated linearly between its two nearest points.
6
6
  */
7
7
  export class Delta {
8
+ /**
9
+ * The deltas a marker draws: one for its `at`, or one for each position of a list, each with the
10
+ * `line`, `text` and `dot` its style functions give for that position.
11
+ */
12
+ static points(marker) {
13
+ const positions = Array.isArray(marker.at) ? marker.at : [marker.at];
14
+ const own = (style, at) => typeof style === 'function' ? style(at) : style;
15
+ return positions.map((at) => ({
16
+ ...marker,
17
+ at,
18
+ line: own(marker.line, at),
19
+ text: own(marker.text, at),
20
+ dot: own(marker.dot, at)
21
+ }));
22
+ }
8
23
  /** The delta `config` asks for over `allSeries`, or `undefined` while an end has no value there. */
9
24
  static build(axis, allSeries, config) {
10
25
  const at = Number(config.at);
@@ -1,96 +1,18 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
- import { Line } from 'svelteplot';
3
- import DotMarker from '../dot/DotMarker.svelte';
4
- import TextMarker from '../text/TextMarker.svelte';
2
+ import DeltaPoint from './DeltaPoint.svelte';
5
3
  import { Delta } from './Delta';
6
- import { getConfiguration } from '../../configuration/config.svelte';
7
4
  import type { Series } from '../../types/plots/data/common';
8
5
  import type { DeltaMarkerConfig } from '../../types/markers/axis/delta';
9
6
 
7
+ /**
8
+ * A delta annotation: one at `marker.at`, or, when it is a list, one at each position - all with the
9
+ * same ends. `line`, `text` and `dot` can be functions of the position.
10
+ */
10
11
  let { marker, data = [] }: { marker: DeltaMarkerConfig; data?: Series<TData>[] } = $props();
11
12
 
12
- const resolved = $derived(Delta.build(marker.axis, data, marker));
13
- const label = $derived(getConfiguration().delta.text[marker.axis]);
14
- const lineAnchor = $derived(marker.text?.lineAnchor ?? label.lineAnchor);
15
-
16
- const clampRange = $derived.by((): [number, number] | undefined => {
17
- const domain = marker.axis === 'x' ? marker.yDomain : marker.xDomain;
18
- if (!domain || domain.length < 2) return undefined;
19
- const lo = Number(domain[0]);
20
- const hi = Number(domain[domain.length - 1]);
21
- return isNaN(lo) || isNaN(hi) ? undefined : [Math.min(lo, hi), Math.max(lo, hi)];
22
- });
23
-
24
- function clamp(v: number): number {
25
- return clampRange ? Math.max(clampRange[0], Math.min(clampRange[1], v)) : v;
26
- }
27
-
28
- function pt(fixed: number, varying: number) {
29
- return marker.axis === 'x' ? { x: fixed, y: varying } : { x: varying, y: fixed };
30
- }
31
-
32
- function textVaryCoord(fromVis: number, toVis: number) {
33
- return lineAnchor === 'top'
34
- ? Math.max(fromVis, toVis)
35
- : lineAnchor === 'bottom'
36
- ? Math.min(fromVis, toVis)
37
- : (fromVis + toVis) / 2;
38
- }
39
-
40
- // Pre-derive all coordinates as script-level signals so prop getters read safe
41
- // values during Svelte 5 teardown, before the {#if} guard finishes unmounting.
42
- const coords = $derived.by(() => {
43
- if (!resolved) return null;
44
- const fromVis = clamp(resolved.from ?? 0);
45
- const toVis = clamp(resolved.to ?? 0);
46
- const at = resolved.at ?? 0;
47
- return {
48
- fromPt: pt(at, fromVis),
49
- toPt: pt(at, toVis),
50
- textPt: pt(at, textVaryCoord(fromVis, toVis)),
51
- lineData: [pt(at, fromVis), pt(at, toVis)],
52
- diff: resolved.diff ?? 0
53
- };
54
- });
55
-
56
- const dotStyles = $derived({
57
- dotRadius: 4,
58
- dotFill: marker.line?.stroke,
59
- dotStroke: marker.line?.stroke,
60
- dotStrokeWidth: 2,
61
- ...marker.dot
62
- });
13
+ const points = $derived(Delta.points(marker));
63
14
  </script>
64
15
 
65
- {#if coords}
66
- <Line
67
- data={coords.lineData}
68
- x={(p) => p.x}
69
- y={(p) => p.y}
70
- stroke={marker.line?.stroke ?? 'currentColor'}
71
- strokeWidth={marker.line?.strokeWidth ?? 2}
72
- strokeOpacity={marker.line?.strokeOpacity ?? 0.8}
73
- strokeDasharray={marker.line?.strokeDasharray ?? '6 6'}
74
- strokeLinecap={marker.line?.strokeLinecap ?? 'round'}
75
- />
76
- <DotMarker marker={{ type: 'dot', x: coords.fromPt.x, y: coords.fromPt.y, styles: dotStyles }} />
77
- <DotMarker marker={{ type: 'dot', x: coords.toPt.x, y: coords.toPt.y, styles: dotStyles }} />
78
- <TextMarker
79
- marker={{
80
- type: 'text',
81
- x: coords.textPt.x,
82
- y: coords.textPt.y,
83
- text: (marker.formatDiff ?? ((v: number) => v.toFixed(1)))(coords.diff),
84
- styles: {
85
- fontWeight: 'bold',
86
- fontSize: 14,
87
- textAnchor: label.textAnchor,
88
- fill: marker.line?.stroke ?? 'currentColor',
89
- ...marker.text,
90
- dx: marker.text?.dx ?? label.dx,
91
- dy: marker.text?.dy ?? label.dy,
92
- lineAnchor
93
- }
94
- }}
95
- />
96
- {/if}
16
+ {#each points as point (String(point.at))}
17
+ <DeltaPoint marker={point} {data} />
18
+ {/each}