@fundar/data-chart-telling 0.0.49 → 0.0.51

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 (40) hide show
  1. package/dist/configuration/config.svelte.js +7 -2
  2. package/dist/configuration/themes/index.d.ts +12 -0
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/layout/legend/DiscreteBadgeItem.svelte +21 -8
  6. package/dist/layout/legend/DiscreteBadgeItem.svelte.d.ts +1 -0
  7. package/dist/layout/legend/DiscreteSection.svelte +22 -9
  8. package/dist/layout/legend/DiscreteSection.svelte.d.ts +2 -1
  9. package/dist/layout/legend/LegendLayout.svelte +7 -2
  10. package/dist/layout/scales/ScalesLayout.svelte +87 -10
  11. package/dist/layout/scales/buildScale.d.ts +18 -1
  12. package/dist/layout/scales/buildScale.js +49 -2
  13. package/dist/layout/scales/niceDomain.d.ts +6 -0
  14. package/dist/layout/scales/niceDomain.js +34 -0
  15. package/dist/layout/tooltip/controller.svelte.js +23 -11
  16. package/dist/layout/tooltip/utils.js +3 -0
  17. package/dist/markers/BarMarker.svelte +145 -84
  18. package/dist/markers/HoverMarker.svelte +41 -1
  19. package/dist/markers/HoverMarker.svelte.d.ts +7 -1
  20. package/dist/plots/bar/Plot.svelte +46 -20
  21. package/dist/plots/bar/buildBarMarkers.d.ts +4 -14
  22. package/dist/plots/bar/buildBarMarkers.js +73 -53
  23. package/dist/plots/bar/hoverPoints.d.ts +1 -1
  24. package/dist/plots/bar/hoverPoints.js +2 -3
  25. package/dist/plots/bar/layout.svelte.d.ts +4 -0
  26. package/dist/plots/bar/layout.svelte.js +62 -17
  27. package/dist/plots/line/Plot.svelte +29 -14
  28. package/dist/plots/line/buildLineMarkers.d.ts +1 -0
  29. package/dist/plots/line/buildLineMarkers.js +3 -4
  30. package/dist/plots/pyramid/Plot.svelte +10 -6
  31. package/dist/plots/scatter/Plot.svelte +25 -11
  32. package/dist/plots/scatter/buildScatterMarkers.d.ts +1 -0
  33. package/dist/plots/scatter/buildScatterMarkers.js +3 -4
  34. package/dist/plots/utils/legendDisabled.d.ts +13 -1
  35. package/dist/plots/utils/legendDisabled.js +27 -0
  36. package/dist/types/configuration/styling.d.ts +12 -2
  37. package/dist/types/layout/legend.d.ts +15 -10
  38. package/dist/types/layout/tooltip.d.ts +2 -1
  39. package/dist/types/markers/common.d.ts +14 -2
  40. package/package.json +1 -1
@@ -1,36 +1,25 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
- import { paletteColor } from '../../utils/color';
3
2
  import { getConfiguration } from '../../configuration/config.svelte';
4
3
  import { isGapValue, resolveGapAreaStyle } from '../utils/gaps';
5
4
  const GAP_KINDS = ['start', 'middle', 'end'];
6
5
  /**
7
- * Every `'bar'` marker for one BarPlot render: one `role: 'segment'` marker
8
- * per series (stacked) or per visual segment (grouped/overlap), one
9
- * additional `role: 'segment'` marker per series per gap zone kind for any
10
- * row `styles.gaps` fills (distinctly styled, drawn at its interpolated
11
- * value — see `resolveDiscreteGapFills`), plus one `role: 'hitArea'` marker
12
- * per category, so a chart never gets segment markers without their hit
13
- * areas. A row whose value is missing and *not* filled by a gap zone is
14
- * skipped entirely — no bar drawn, rather than the broken zero/`NaN`
15
- * geometry a raw missing value would otherwise produce.
16
- *
17
- * `'hitArea'` geometry is left unresolved (just `category` + `data`) —
18
- * resolving it to pixels needs svelteplot's live scale, only available
19
- * where the marker actually renders.
6
+ * Every `'bar'` marker for one BarPlot render: visible `role: 'segment'`
7
+ * bars (real data + gap fills) plus one `role: 'hitArea'` hit region per row,
8
+ * sized to that row's own segment.
20
9
  */
21
10
  export function buildBarMarkers(args) {
22
- const { groupedSeries, barLayout, isHorizontal, hoverPoints, palette, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
11
+ const { groupedSeries, barLayout, isHorizontal, hoverPoints, colorFor, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
23
12
  const gapDefaults = getConfiguration().bar.gap;
13
+ // Series names eligible for hover (excludes legend-disabled ones).
14
+ const hoverEligibleSeries = new Set(hoverPoints.map((p) => p.series));
24
15
  const segmentMarkers = [];
16
+ const hitAreaMarkers = [];
25
17
  groupedSeries.forEach((group, seriesIndex) => {
26
18
  const xFn = resolveAccessor(group.series.x);
27
19
  const yFn = resolveAccessor(group.series.y);
28
- const defaultColor = paletteColor(seriesIndex, palette);
20
+ const defaultColor = colorFor(group.series.name);
29
21
  const disabledFill = disabledFillFor(group.series.name);
30
- // Places one series' bar(s) real or gap-fill — using whichever
31
- // layout mode (stacked baseline, or flat baseline + grouped insets) is
32
- // active, so a gap-fill bar always lands in exactly the same slot a
33
- // real bar for that row would.
22
+ // Builds one series' visible bar geometry (real or gap-fill data).
34
23
  function makeMarker(data, valueFn, style) {
35
24
  if (data.length === 0)
36
25
  return null;
@@ -61,31 +50,81 @@ export function buildBarMarkers(args) {
61
50
  marker.y2 = valueFn;
62
51
  }
63
52
  if (barLayout.layout === 'grouped') {
53
+ const { start, end } = barLayout.groupInsetFraction(seriesIndex);
64
54
  if (isHorizontal) {
65
- marker.insetTop = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
66
- marker.insetBottom = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
55
+ marker.insetTop = start;
56
+ marker.insetBottom = end;
67
57
  }
68
58
  else {
69
- marker.insetLeft = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
70
- marker.insetRight = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
59
+ marker.insetLeft = start;
60
+ marker.insetRight = end;
61
+ }
62
+ }
63
+ }
64
+ return marker;
65
+ }
66
+ // Builds one row's hit-test geometry — same shape as `makeMarker`, for one row.
67
+ function makeHitAreaMarker(d, valueFn) {
68
+ const marker = {
69
+ type: 'bar',
70
+ role: 'hitArea',
71
+ isHorizontal,
72
+ data: [d],
73
+ category: xFn(d)
74
+ };
75
+ if (barLayout.layout === 'stacked') {
76
+ const baseline = barLayout.stackedBaseline(xFn(d), seriesIndex);
77
+ const top = baseline + valueFn(d);
78
+ if (isHorizontal) {
79
+ marker.x1 = baseline;
80
+ marker.x2 = top;
81
+ }
82
+ else {
83
+ marker.y1 = baseline;
84
+ marker.y2 = top;
85
+ }
86
+ }
87
+ else {
88
+ if (isHorizontal) {
89
+ marker.x1 = barLayout.valueBaseline;
90
+ marker.x2 = valueFn(d);
91
+ }
92
+ else {
93
+ marker.y1 = barLayout.valueBaseline;
94
+ marker.y2 = valueFn(d);
95
+ }
96
+ if (barLayout.layout === 'grouped') {
97
+ const { start, end } = barLayout.groupInsetFraction(seriesIndex);
98
+ if (isHorizontal) {
99
+ marker.insetTop = start;
100
+ marker.insetBottom = end;
101
+ }
102
+ else {
103
+ marker.insetLeft = start;
104
+ marker.insetRight = end;
71
105
  }
72
106
  }
73
107
  }
74
108
  return marker;
75
109
  }
76
- // Mirrors the pre-existing behavior for both branches: a series-scoped
77
- // `segments` catch-all style (e.g. an explicit `fill`) wins over the
78
- // index-derived `defaultColor`.
79
110
  for (const seg of group.visualSegments) {
80
111
  const realData = seg.data.filter((d) => !isGapValue(yFn(d)));
81
112
  const style = {
82
113
  fill: disabledFill?.fill ?? seg.style.fill ?? defaultColor,
83
- fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1,
114
+ fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1
84
115
  };
85
116
  const valueFn = barLayout.layout === 'stacked' ? (d) => Number(yFn(d)) : yFn;
86
117
  const marker = makeMarker(realData, valueFn, style);
87
118
  if (marker)
88
119
  segmentMarkers.push(marker);
120
+ if (hoverEligibleSeries.has(group.series.name)) {
121
+ for (const d of realData) {
122
+ const hitMarker = makeHitAreaMarker(d, valueFn);
123
+ hitMarker.onpointerenter = active ? () => setHoverMatch([d]) : undefined;
124
+ hitMarker.onpointerleave = active ? clearHoverMatch : undefined;
125
+ hitAreaMarkers.push(hitMarker);
126
+ }
127
+ }
89
128
  }
90
129
  const fillMap = gapFillMaps.get(group.series.name) ?? new Map();
91
130
  for (const kind of GAP_KINDS) {
@@ -96,36 +135,17 @@ export function buildBarMarkers(args) {
96
135
  const gapStyle = {
97
136
  ...base,
98
137
  fill: disabledFill?.fill ?? base.fill,
99
- fillOpacity: disabledFill?.fillOpacity ?? base.fillOpacity,
138
+ fillOpacity: disabledFill?.fillOpacity ?? base.fillOpacity
100
139
  };
101
- // svelteplot shallow-clones each row before invoking a mark's own
102
- // geometry accessors (same reason PyramidPlot's `flat` tags rows
103
- // instead of relying on identity) — a `fillMap.get(d)` lookup inside
104
- // `valueFn` would see a clone, not the original key. Tagging the
105
- // resolved value onto the row as a plain field survives that clone.
106
- const taggedData = filledRows.map((d) => ({ ...d, __gapValue: fillMap.get(d).value }));
140
+ // Tags each row with its resolved gap-fill value as a plain field.
141
+ const taggedData = filledRows.map((d) => ({
142
+ ...d,
143
+ __gapValue: fillMap.get(d).value
144
+ }));
107
145
  const marker = makeMarker(taggedData, (d) => d.__gapValue, gapStyle);
108
146
  if (marker)
109
147
  segmentMarkers.push(marker);
110
148
  }
111
149
  });
112
- const byCategory = new Map();
113
- for (const p of hoverPoints) {
114
- const key = isHorizontal ? p.y : p.x;
115
- const list = byCategory.get(key);
116
- if (list)
117
- list.push(p);
118
- else
119
- byCategory.set(key, [p]);
120
- }
121
- const hitAreaMarkers = [...byCategory.entries()].map(([category, points]) => ({
122
- type: 'bar',
123
- role: 'hitArea',
124
- isHorizontal,
125
- category,
126
- data: points.map((p) => p.row),
127
- onpointerenter: active ? () => setHoverMatch(points.map((p) => p.row)) : undefined,
128
- onpointerleave: active ? clearHoverMatch : undefined,
129
- }));
130
150
  return [...segmentMarkers, ...hitAreaMarkers];
131
151
  }
@@ -12,4 +12,4 @@ import type { BarLayout } from './layout.svelte';
12
12
  * height instead of `dx`/`dy`, since that offset is representable as a
13
13
  * regular data value (unlike 'grouped''s pixel-only slice offset).
14
14
  */
15
- export declare function buildHoverPoints<TData extends Record<string, unknown>>(resolvedSeries: Series<TData>[], isHorizontal: boolean, geometry: Pick<BarLayout<TData>, 'layout' | 'stackedBaseline' | 'groupOffset'>, palette: string[]): HoverPoint<TData>[];
15
+ export declare function buildHoverPoints<TData extends Record<string, unknown>>(resolvedSeries: Series<TData>[], isHorizontal: boolean, geometry: Pick<BarLayout<TData>, 'layout' | 'stackedBaseline' | 'groupOffset'>, colorFor: (seriesName: string) => string): HoverPoint<TData>[];
@@ -1,5 +1,4 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
- import { paletteColor } from '../../utils/color';
3
2
  import { isGapValue } from '../utils/gaps';
4
3
  /**
5
4
  * Builds hover-marker candidate points for every row across every series.
@@ -12,7 +11,7 @@ import { isGapValue } from '../utils/gaps';
12
11
  * height instead of `dx`/`dy`, since that offset is representable as a
13
12
  * regular data value (unlike 'grouped''s pixel-only slice offset).
14
13
  */
15
- export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, palette) {
14
+ export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, colorFor) {
16
15
  return resolvedSeries.flatMap((s, idx) => {
17
16
  const sx = resolveAccessor(s.x);
18
17
  const sy = resolveAccessor(s.y);
@@ -29,7 +28,7 @@ export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, palette
29
28
  row: d,
30
29
  label: String(sy(d)),
31
30
  series: s.name,
32
- color: paletteColor(idx, palette),
31
+ color: colorFor(s.name),
33
32
  };
34
33
  });
35
34
  });
@@ -34,5 +34,9 @@ export declare function createBarLayout<TData extends Record<string, unknown>>(a
34
34
  dx: number;
35
35
  dy: number;
36
36
  };
37
+ groupInsetFraction: (seriesIndex: number) => {
38
+ start: number;
39
+ end: number;
40
+ };
37
41
  };
38
42
  export type BarLayout<TData extends Record<string, unknown>> = ReturnType<typeof createBarLayout<TData>>;
@@ -58,7 +58,12 @@ export function createBarLayout(args) {
58
58
  * band-scale formula (and 0.15 default padding) svelteplot itself uses,
59
59
  * so the category axis's own `paddingInner`/`paddingOuter` — the space
60
60
  * *between* categories — is reflected here too, not just svelteplot's
61
- * real render.
61
+ * real render. In practice `categoricalScale` always carries a resolved
62
+ * `paddingInner`/`paddingOuter` by the time it gets here — `Plot.svelte`
63
+ * backs its own category axis with the theme's `bar.paddingInner`/
64
+ * `paddingOuter` before either this estimate or the real svelteplot
65
+ * scale sees it, so the two stay in sync; the literal fallback below is
66
+ * only a defensive last resort.
62
67
  */
63
68
  const estimatedBandwidth = $derived.by(() => {
64
69
  const categoryCount = new SvelteSet(args.flat().map((d) => args.xAcc()(d))).size;
@@ -66,19 +71,25 @@ export function createBarLayout(args) {
66
71
  return 0;
67
72
  const baseMargins = { ...cfg.margins, ...args.margins() };
68
73
  const innerSize = args.isHorizontal()
69
- ? Math.max(0, args.height() - numericMargin('top', baseMargins.top) - numericMargin('bottom', baseMargins.bottom))
70
- : Math.max(0, args.width() - numericMargin('left', baseMargins.left) - numericMargin('right', baseMargins.right));
74
+ ? Math.max(0, args.height() -
75
+ numericMargin('top', baseMargins.top) -
76
+ numericMargin('bottom', baseMargins.bottom))
77
+ : Math.max(0, args.width() -
78
+ numericMargin('left', baseMargins.left) -
79
+ numericMargin('right', baseMargins.right));
71
80
  const paddingInner = categoricalScale?.paddingInner ?? categoricalScale?.padding ?? 0.15;
72
81
  const paddingOuter = categoricalScale?.paddingOuter ?? categoricalScale?.padding ?? 0.15;
73
82
  const step = innerSize / (categoryCount - paddingInner + 2 * paddingOuter);
74
83
  return step * (1 - paddingInner);
75
84
  });
76
85
  const seriesCount = $derived(args.groupedSeries().length);
77
- // Each series gets an equal slice of the band; `seriesPadding` (0–1, space
78
- // *within* a group, between its series) reserves a fraction of that slice
79
- // as a gap split evenly on either side of the bar.
86
+ // `seriesPadding` (0–1, space *within* a group, between its series)
87
+ // reserves a fraction of each series' own slice as a gap split evenly on
88
+ // either side of the bar.
89
+ const seriesGapFraction = $derived(Math.max(0, Math.min(1, args.scales().z?.seriesPadding ?? cfg.bar.seriesPadding)));
90
+ // Each series gets an equal slice of the band.
80
91
  const seriesStep = $derived(seriesCount > 0 ? estimatedBandwidth / seriesCount : 0);
81
- const seriesGap = $derived(Math.max(0, Math.min(1, args.scales().z?.seriesPadding ?? 0)) * seriesStep);
92
+ const seriesGap = $derived(seriesGapFraction * seriesStep);
82
93
  /**
83
94
  * Per-category cumulative sums for 'stacked' layout — `stackedPrefixSums.get(category)[i]`
84
95
  * is the sum of series `0..i-1`'s value at that category, i.e. series `i`'s
@@ -114,24 +125,58 @@ export function createBarLayout(args) {
114
125
  }
115
126
  /**
116
127
  * Pixel offset for a 'grouped' series' own slice of the category band —
117
- * shared by bar segments (`insetLeft`/`insetTop`), value labels
118
- * (`dx`/`dy`), and hover markers, so all three stay in sync with wherever
119
- * this series' bar actually renders.
128
+ * shared by bar segments, value labels, and hover markers. `dy` is
129
+ * negated for horizontal bars, matching how `insetBottom` positions them.
120
130
  */
121
131
  function groupOffset(seriesIndex) {
122
132
  if (layout !== 'grouped')
123
133
  return { dx: 0, dy: 0 };
124
134
  const offset = -(estimatedBandwidth / 2) + seriesIndex * seriesStep + seriesStep / 2;
125
- return args.isHorizontal() ? { dx: 0, dy: offset } : { dx: offset, dy: 0 };
135
+ return args.isHorizontal() ? { dx: 0, dy: -offset } : { dx: offset, dy: 0 };
136
+ }
137
+ /**
138
+ * A 'grouped' series' own slice of the category band, as fractions (0–1)
139
+ * of the *whole* band rather than pixels — unlike {@link groupOffset},
140
+ * which bakes in `estimatedBandwidth`'s own guess, these stay exact
141
+ * regardless of how far off that guess ends up: `BarMarker` multiplies
142
+ * them by the band's real, svelteplot-measured width at render time
143
+ * (only available once mounted inside the actual `<Plot>`, which this
144
+ * module isn't), so a bar's own visible width/position is never off by
145
+ * the estimate's error the way `estimatedBandwidth`-derived pixels can be
146
+ * — most noticeable in a small faceted cell, where a fixed margin
147
+ * estimate is proportionally least accurate.
148
+ */
149
+ function groupInsetFraction(seriesIndex) {
150
+ if (layout !== 'grouped' || seriesCount === 0)
151
+ return { start: 0, end: 0 };
152
+ const sliceFraction = 1 / seriesCount;
153
+ const gapFraction = (seriesGapFraction * sliceFraction) / 2;
154
+ return {
155
+ start: seriesIndex * sliceFraction + gapFraction,
156
+ end: (seriesCount - 1 - seriesIndex) * sliceFraction + gapFraction
157
+ };
126
158
  }
127
159
  return {
128
- get layout() { return layout; },
129
- get valueBaseline() { return valueBaseline; },
130
- get estimatedBandwidth() { return estimatedBandwidth; },
131
- get seriesCount() { return seriesCount; },
132
- get seriesStep() { return seriesStep; },
133
- get seriesGap() { return seriesGap; },
160
+ get layout() {
161
+ return layout;
162
+ },
163
+ get valueBaseline() {
164
+ return valueBaseline;
165
+ },
166
+ get estimatedBandwidth() {
167
+ return estimatedBandwidth;
168
+ },
169
+ get seriesCount() {
170
+ return seriesCount;
171
+ },
172
+ get seriesStep() {
173
+ return seriesStep;
174
+ },
175
+ get seriesGap() {
176
+ return seriesGap;
177
+ },
134
178
  stackedBaseline,
135
179
  groupOffset,
180
+ groupInsetFraction
136
181
  };
137
182
  }
@@ -4,7 +4,13 @@
4
4
  import { paletteColor } from '../../utils/color';
5
5
  import { buildSeries } from '../../utils/grouping';
6
6
  import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
7
- import { disabledStrokeOverride, disabledDotsOverride } from '../utils/legendDisabled';
7
+ import {
8
+ disabledStrokeOverride,
9
+ disabledDotsOverride,
10
+ resolveEffectiveDisabledStyle,
11
+ omitDisabledSeries,
12
+ buildSeriesColorIndex
13
+ } from '../utils/legendDisabled';
8
14
  import { buildLineMarkers } from './buildLineMarkers';
9
15
  import { buildGapMarkers } from './buildGapMarkers';
10
16
  import { isGapValue } from '../utils/gaps';
@@ -64,8 +70,16 @@
64
70
  // resolved per series.
65
71
  const gaps = $derived(styles.gaps ?? {});
66
72
 
67
- const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
73
+ const cfg = $derived(getConfiguration());
68
74
  const legendInteraction = $derived(getLegendInteraction());
75
+ // Unfiltered series list, for stable per-name color indexing.
76
+ const fullSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
77
+ const colorIndex = $derived(buildSeriesColorIndex(fullSeries));
78
+ function colorForSeries(name: string): string {
79
+ return paletteColor(colorIndex.get(name) ?? 0, cfg.palette);
80
+ }
81
+ // Drops series toggled off with `disabled: { mode: 'omit' }`.
82
+ const resolvedSeries = $derived(omitDisabledSeries(fullSeries, legendInteraction));
69
83
 
70
84
  $effect(() => {
71
85
  validateSegments(segments);
@@ -75,7 +89,6 @@
75
89
  buildGroupedSeries<Series<TData>, LineSegmentStyle>(resolvedSeries, segments)
76
90
  );
77
91
 
78
- const cfg = $derived(getConfiguration());
79
92
  const showVals = $derived(styles.values?.show ?? false);
80
93
  const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
81
94
  const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'start');
@@ -86,11 +99,12 @@
86
99
  let hoverIsolatedSeries = $state<string | null>(null);
87
100
 
88
101
  function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
89
- return (
90
- legendInteraction?.disabledSeries.get(name) ??
91
- (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
92
- ? { opacity: cfg.legend.disabledOpacity }
93
- : undefined)
102
+ return resolveEffectiveDisabledStyle(
103
+ name,
104
+ legendInteraction,
105
+ cfg.legend.disabledOpacity,
106
+ hoverIsolate,
107
+ hoverIsolatedSeries
94
108
  );
95
109
  }
96
110
 
@@ -99,7 +113,7 @@
99
113
  const flat = $derived(resolvedSeries.flatMap((s) => s.data));
100
114
  // Legend-disabled series and gap rows are excluded from hover candidates.
101
115
  const hoverPoints = $derived(
102
- resolvedSeries.flatMap((s, idx) => {
116
+ resolvedSeries.flatMap((s) => {
103
117
  if (legendInteraction?.disabledSeries.has(s.name)) return [];
104
118
  const sx = resolveAccessor(s.x);
105
119
  const sy = resolveAccessor(s.y);
@@ -111,7 +125,7 @@
111
125
  row: d,
112
126
  label: String(sy(d)),
113
127
  series: s.name,
114
- color: paletteColor(idx, cfg.palette)
128
+ color: colorForSeries(s.name)
115
129
  }));
116
130
  })
117
131
  );
@@ -128,6 +142,7 @@
128
142
  const lineMarkers = $derived(
129
143
  buildLineMarkers({
130
144
  groupedSeries,
145
+ colorFor: colorForSeries,
131
146
  disabledStrokeFor: (name) => disabledStrokeOverride(effectiveDisabledFor(name)),
132
147
  disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name))
133
148
  })
@@ -137,7 +152,7 @@
137
152
  buildGapMarkers<TData>({
138
153
  seriesList: resolvedSeries,
139
154
  gaps,
140
- colorFor: (seriesIndex) => paletteColor(seriesIndex, cfg.palette),
155
+ colorFor: (seriesIndex) => colorForSeries(resolvedSeries[seriesIndex].name),
141
156
  disabledStrokeFor: (name) => disabledStrokeOverride(effectiveDisabledFor(name)),
142
157
  disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name))
143
158
  })
@@ -147,14 +162,14 @@
147
162
  buildScopedMarkerGroups<TData, Series<TData>, LineSegmentStyle>({
148
163
  groupedSeries,
149
164
  segments,
150
- colorFor: (_series, seriesIndex) => paletteColor(seriesIndex, cfg.palette),
165
+ colorFor: (series) => colorForSeries(series.name),
151
166
  segmentColorFor: (style, defaultColor) => style?.stroke?.stroke ?? defaultColor
152
167
  })
153
168
  );
154
169
 
155
170
  const lastPoints = $derived(
156
171
  resolvedSeries
157
- .map((s, idx) => {
172
+ .map((s) => {
158
173
  const sy = resolveAccessor(s.y);
159
174
  const lastRow = [...s.data].reverse().find((d) => !isGapValue(sy(d)));
160
175
  if (lastRow === undefined) return undefined;
@@ -164,7 +179,7 @@
164
179
  matchedSeg?.color ??
165
180
  group?.segments[0]?.color ??
166
181
  group?.color ??
167
- paletteColor(idx, cfg.palette);
182
+ colorForSeries(s.name);
168
183
  return { series: s, lastRow, color };
169
184
  })
170
185
  .filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
@@ -10,6 +10,7 @@ import type { LineMarkerConfig, DotMarkerConfig } from '../../types/markers/comm
10
10
  */
11
11
  export declare function buildLineMarkers<TData extends Record<string, unknown>>(args: {
12
12
  groupedSeries: VisualGroup<Series<TData>, LineSegmentStyle>[];
13
+ colorFor: (seriesName: string) => string;
13
14
  disabledStrokeFor: (seriesName: string) => StrokeStyle | undefined;
14
15
  disabledDotsFor: (seriesName: string) => DotStyle | undefined;
15
16
  }): (LineMarkerConfig | DotMarkerConfig)[];
@@ -1,5 +1,4 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
- import { paletteColor } from '../../utils/color';
3
2
  import { getConfiguration } from '../../configuration/config.svelte';
4
3
  /**
5
4
  * Every `'line'` (+ `'dot'`, where a segment's style asks for them) marker
@@ -7,11 +6,11 @@ import { getConfiguration } from '../../configuration/config.svelte';
7
6
  * `'dot'` marker right after it when that segment sets `style.dots`.
8
7
  */
9
8
  export function buildLineMarkers(args) {
10
- const { groupedSeries, disabledStrokeFor, disabledDotsFor } = args;
9
+ const { groupedSeries, colorFor, disabledStrokeFor, disabledDotsFor } = args;
11
10
  const cfg = getConfiguration();
12
11
  const markers = [];
13
- groupedSeries.forEach((group, seriesIndex) => {
14
- const defaultColor = paletteColor(seriesIndex, cfg.palette);
12
+ groupedSeries.forEach((group) => {
13
+ const defaultColor = colorFor(group.series.name);
15
14
  const disabledStroke = disabledStrokeFor(group.series.name);
16
15
  const disabledDots = disabledDotsFor(group.series.name);
17
16
  const xFn = resolveAccessor(group.series.x);
@@ -4,7 +4,10 @@
4
4
  import { paletteColor } from '../../utils/color';
5
5
  import { buildSeries } from '../../utils/grouping';
6
6
  import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
7
- import { disabledFillOverride } from '../utils/legendDisabled';
7
+ import {
8
+ disabledFillOverride,
9
+ resolveEffectiveDisabledStyle
10
+ } from '../utils/legendDisabled';
8
11
  import { buildPyramidBarMarkers } from './buildPyramidBarMarkers';
9
12
  import {
10
13
  createLabelMarginTracker,
@@ -90,11 +93,12 @@
90
93
  let hoverIsolatedSeries = $state<string | null>(null);
91
94
 
92
95
  function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
93
- return (
94
- legendInteraction?.disabledSeries.get(name) ??
95
- (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
96
- ? { opacity: cfg.legend.disabledOpacity }
97
- : undefined)
96
+ return resolveEffectiveDisabledStyle(
97
+ name,
98
+ legendInteraction,
99
+ cfg.legend.disabledOpacity,
100
+ hoverIsolate,
101
+ hoverIsolatedSeries
98
102
  );
99
103
  }
100
104
 
@@ -4,7 +4,12 @@
4
4
  import { paletteColor } from '../../utils/color';
5
5
  import { buildSeries } from '../../utils/grouping';
6
6
  import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
7
- import { disabledDotsOverride } from '../utils/legendDisabled';
7
+ import {
8
+ disabledDotsOverride,
9
+ resolveEffectiveDisabledStyle,
10
+ omitDisabledSeries,
11
+ buildSeriesColorIndex
12
+ } from '../utils/legendDisabled';
8
13
  import { buildScatterMarkers } from './buildScatterMarkers';
9
14
  import { resolveRadiusPaddedDomain } from './resolveRadiusPaddedDomain';
10
15
  import {
@@ -67,8 +72,16 @@
67
72
  tooltip = undefined
68
73
  }: Props = $props();
69
74
 
70
- const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
75
+ const cfg = $derived(getConfiguration());
71
76
  const legendInteraction = $derived(getLegendInteraction());
77
+ // Unfiltered series list, for stable per-name color indexing.
78
+ const fullSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
79
+ const colorIndex = $derived(buildSeriesColorIndex(fullSeries));
80
+ function colorForSeries(name: string): string {
81
+ return paletteColor(colorIndex.get(name) ?? 0, cfg.palette);
82
+ }
83
+ // Drops series toggled off with `disabled: { mode: 'omit' }`.
84
+ const resolvedSeries = $derived(omitDisabledSeries(fullSeries, legendInteraction));
72
85
 
73
86
  $effect(() => {
74
87
  validateSegments(segments);
@@ -81,7 +94,6 @@
81
94
  buildGroupedSeries<Series<TData>, ScatterSegmentStyle>(resolvedSeries, segments, false)
82
95
  );
83
96
 
84
- const cfg = $derived(getConfiguration());
85
97
  const showVals = $derived(styles.values?.show ?? false);
86
98
  const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
87
99
  const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
@@ -93,11 +105,12 @@
93
105
  let hoverIsolatedSeries = $state<string | null>(null);
94
106
 
95
107
  function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
96
- return (
97
- legendInteraction?.disabledSeries.get(name) ??
98
- (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
99
- ? { opacity: cfg.legend.disabledOpacity }
100
- : undefined)
108
+ return resolveEffectiveDisabledStyle(
109
+ name,
110
+ legendInteraction,
111
+ cfg.legend.disabledOpacity,
112
+ hoverIsolate,
113
+ hoverIsolatedSeries
101
114
  );
102
115
  }
103
116
 
@@ -108,7 +121,7 @@
108
121
  // A legend-disabled series is dimmed on the mark itself, but never surfaces
109
122
  // in hover/tooltip — its points are simply absent from the candidate list.
110
123
  const hoverPoints = $derived(
111
- resolvedSeries.flatMap((s, idx) => {
124
+ resolvedSeries.flatMap((s) => {
112
125
  if (legendInteraction?.disabledSeries.has(s.name)) return [];
113
126
  const sx = resolveAccessor(s.x);
114
127
  const sy = resolveAccessor(s.y);
@@ -118,7 +131,7 @@
118
131
  row: d,
119
132
  label: `${sx(d)}, ${sy(d)}`,
120
133
  series: s.name,
121
- color: paletteColor(idx, cfg.palette)
134
+ color: colorForSeries(s.name)
122
135
  }));
123
136
  })
124
137
  );
@@ -138,6 +151,7 @@
138
151
  buildScatterMarkers({
139
152
  groupedSeries,
140
153
  r,
154
+ colorFor: colorForSeries,
141
155
  disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name))
142
156
  })
143
157
  );
@@ -146,7 +160,7 @@
146
160
  buildScopedMarkerGroups<TData, Series<TData>, ScatterSegmentStyle>({
147
161
  groupedSeries,
148
162
  segments,
149
- colorFor: (_series, seriesIndex) => paletteColor(seriesIndex, cfg.palette),
163
+ colorFor: (series) => colorForSeries(series.name),
150
164
  segmentColorFor: (style, defaultColor) => style?.dotFill ?? defaultColor
151
165
  })
152
166
  );
@@ -7,5 +7,6 @@ import type { DotMarkerConfig } from '../../types/markers/common';
7
7
  export declare function buildScatterMarkers<TData extends Record<string, unknown>>(args: {
8
8
  groupedSeries: VisualGroup<Series<TData>, ScatterSegmentStyle>[];
9
9
  r?: Accessor<TData, number> | number;
10
+ colorFor: (seriesName: string) => string;
10
11
  disabledDotsFor: (seriesName: string) => DotStyle | undefined;
11
12
  }): DotMarkerConfig[];
@@ -1,14 +1,13 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
- import { paletteColor } from '../../utils/color';
3
2
  import { getConfiguration } from '../../configuration/config.svelte';
4
3
  /** Every `'dot'` marker for one ScatterPlot render: one per visual segment. */
5
4
  export function buildScatterMarkers(args) {
6
- const { groupedSeries, r, disabledDotsFor } = args;
5
+ const { groupedSeries, r, colorFor, disabledDotsFor } = args;
7
6
  const cfg = getConfiguration();
8
7
  const rFn = r === undefined || typeof r === 'number' ? r : resolveAccessor(r);
9
8
  const markers = [];
10
- groupedSeries.forEach((group, seriesIndex) => {
11
- const defaultColor = paletteColor(seriesIndex, cfg.palette);
9
+ groupedSeries.forEach((group) => {
10
+ const defaultColor = colorFor(group.series.name);
12
11
  const disabledDots = disabledDotsFor(group.series.name);
13
12
  const xFn = resolveAccessor(group.series.x);
14
13
  const yFn = resolveAccessor(group.series.y);
@@ -1,5 +1,17 @@
1
- import type { LegendDisabledStyle } from '../../types/layout/legend';
1
+ import type { LegendDisabledAction, LegendDisabledStyle, LegendInteractionStore } from '../../types/layout/legend';
2
2
  import type { StrokeStyle, DotStyle } from '../../types/plots/styling';
3
+ /** Resolves a {@link LegendDisabledAction} into the style a mark should render with — `'omit'` resolves to fully transparent. */
4
+ export declare function resolveDisabledStyle(action: LegendDisabledAction | undefined, fallbackOpacity: number): LegendDisabledStyle | undefined;
5
+ /** Drops series toggled off with `disabled: { mode: 'omit' }` from a series list. */
6
+ export declare function omitDisabledSeries<S extends {
7
+ name: string;
8
+ }>(series: S[], legendInteraction: LegendInteractionStore | null | undefined): S[];
9
+ /** Builds a per-series-name → palette index from the full series list, before {@link omitDisabledSeries} filters it. */
10
+ export declare function buildSeriesColorIndex<S extends {
11
+ name: string;
12
+ }>(series: S[]): Map<string, number>;
13
+ /** Resolves the disabled style for a series by name: legend-off state, falling back to hover-isolate dim. */
14
+ export declare function resolveEffectiveDisabledStyle(name: string, legendInteraction: LegendInteractionStore | null | undefined, disabledOpacity: number, hoverIsolate: boolean, hoverIsolatedSeries: string | null): LegendDisabledStyle | undefined;
3
15
  /**
4
16
  * The three per-mark override layers a legend "disabled" state can apply,
5
17
  * derived once from a {@link LegendDisabledStyle}. Each is `undefined` when