@fundar/data-chart-telling 0.0.48 → 0.0.50

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.
@@ -123,7 +123,7 @@ export const DEFAULT_CONFIG = {
123
123
  rule: { stroke: '#e6580d', strokeOpacity: 0.35, strokeWidth: 1.5, strokeDasharray: '4 4' },
124
124
  dot: { dotRadius: 5, dotFill: 'currentColor' },
125
125
  font: { fill: 'currentColor', fontSize: 12, dy: -18 },
126
- area: { stroke: '#e6580d', strokeWidth: 2, fillOpacity: 0 }
126
+ area: { fill: '#94a3b8', fillOpacity: 0.25 }
127
127
  },
128
128
  facet: {
129
129
  indicator: {
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { default as TextMarker } from './markers/TextMarker.svelte';
34
34
  export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
35
35
  export { THEMES } from './configuration/themes';
36
36
  export type { ThemeName } from './configuration/themes';
37
- export { groupBy, buildSeries } from './utils/grouping';
37
+ export { groupBy } from './utils/grouping';
38
38
  export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
39
39
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
40
40
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
@@ -58,7 +58,7 @@ export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, Geo
58
58
  export type { ChartPlotContext, ChartPlotSnippet } from './types/charts/common';
59
59
  export type { TimeValue, Orientation, TimelineConfig } from './types/layout/timeline';
60
60
  export type { Responsive, FacetColumns, FacetMode, FacetNavLayout, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet } from './types/layout/facet';
61
- export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendItemsLayout, LegendInteractiveStyle, DiscreteInteractiveConfig, ContinuousInteractiveConfig, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
61
+ export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendDisabledAction, LegendItemsLayout, LegendInteractiveStyle, DiscreteInteractiveConfig, ContinuousInteractiveConfig, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
62
62
  export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY } from './types/layout/tooltip';
63
63
  export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
64
64
  export type { ChartProps, GapsAwareChartProps } from './types/charts/props';
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { default as TextMarker } from './markers/TextMarker.svelte';
41
41
  export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
42
42
  export { THEMES } from './configuration/themes';
43
43
  // ── Utils ─────────────────────────────────────────────────────────────────────
44
- export { groupBy, buildSeries } from './utils/grouping';
44
+ export { groupBy } from './utils/grouping';
45
45
  export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
46
46
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
47
47
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
@@ -4,18 +4,23 @@
4
4
  import type { LegendItem } from '../../types/layout/legend';
5
5
 
6
6
  /**
7
- * The `interactive.legendStyle: 'badge'` item look: a pill filled with the
8
- * series color, its symbol icon (unless `showIcon` is off) and label
9
- * auto-contrasted against that fill, and an eye icon that's crossed out
10
- * while `disabled`. The toggled-off dim itself is applied by the parent
11
- * `.dct-discrete__item`.
7
+ * The `interactive.legendStyle: 'badge'` item look: a pill with the series
8
+ * color, symbol icon, label, and a toggle icon — an eye (crossed out when
9
+ * `disabled`) for `'style'` mode, or a plus/cross pair for `'omit'`.
12
10
  */
13
11
  let {
14
12
  item,
15
13
  color,
16
14
  disabled,
17
- showIcon = true
18
- }: { item: LegendItem; color: string; disabled: boolean; showIcon?: boolean } = $props();
15
+ showIcon = true,
16
+ omitMode = false
17
+ }: {
18
+ item: LegendItem;
19
+ color: string;
20
+ disabled: boolean;
21
+ showIcon?: boolean;
22
+ omitMode?: boolean;
23
+ } = $props();
19
24
  const textColor = $derived(contrastTextColor(color));
20
25
  </script>
21
26
 
@@ -25,7 +30,15 @@
25
30
  {/if}
26
31
  <span class="dct-discrete__label">{item.label || item.name}</span>
27
32
  <svg class="dct-discrete__eye" viewBox="0 0 24 24" aria-hidden="true">
28
- {#if disabled}
33
+ {#if omitMode}
34
+ {#if disabled}
35
+ <line x1="12" y1="5" x2="12" y2="19" />
36
+ <line x1="5" y1="12" x2="19" y2="12" />
37
+ {:else}
38
+ <line x1="6" y1="6" x2="18" y2="18" />
39
+ <line x1="6" y1="18" x2="18" y2="6" />
40
+ {/if}
41
+ {:else if disabled}
29
42
  <path
30
43
  d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
31
44
  />
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  color: string;
5
5
  disabled: boolean;
6
6
  showIcon?: boolean;
7
+ omitMode?: boolean;
7
8
  };
8
9
  declare const DiscreteBadgeItem: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type DiscreteBadgeItem = ReturnType<typeof DiscreteBadgeItem>;
@@ -6,18 +6,25 @@
6
6
  getLegendInteraction,
7
7
  createLegendInteraction
8
8
  } from './interaction.svelte';
9
+ import { resolveDisabledStyle } from '../../plots/utils/legendDisabled';
9
10
  import DiscreteBadgeItem from './DiscreteBadgeItem.svelte';
10
11
  import DiscreteSwatchItem from './DiscreteSwatchItem.svelte';
11
12
  import type {
12
13
  DiscreteLegendSection,
14
+ LegendDisabledAction,
13
15
  LegendItem,
16
+ LegendItemsLayout,
14
17
  LegendNavDirection
15
18
  } from '../../types/layout/legend';
16
19
 
17
- let { section }: { section: DiscreteLegendSection } = $props();
20
+ /** `defaultItemsLayout` is the fallback used when `section.layout` isn't set. */
21
+ let {
22
+ section,
23
+ defaultItemsLayout = { type: 'vertical' }
24
+ }: { section: DiscreteLegendSection; defaultItemsLayout?: LegendItemsLayout } = $props();
18
25
 
19
26
  const cfg = $derived(getConfiguration());
20
- const layout = $derived(section.layout ?? { type: 'vertical' as const });
27
+ const layout = $derived(section.layout ?? defaultItemsLayout);
21
28
  const isHorizontal = $derived(layout.type === 'horizontal');
22
29
  const maxRows = $derived(layout.type === 'horizontal' ? Math.max(1, layout.maxRows ?? 1) : 1);
23
30
  const columns = $derived(section.columns ?? 1);
@@ -26,8 +33,8 @@
26
33
  isInteractive && (section.interactive?.legendStyle ?? 'badge') === 'badge'
27
34
  );
28
35
  const showIcon = $derived(section.interactive?.showIcon ?? true);
29
- const disabledStyle = $derived(
30
- section.interactive?.disabledStyle ?? { opacity: cfg.legend.disabledOpacity }
36
+ const disabledAction = $derived<LegendDisabledAction>(
37
+ section.interactive?.disabled ?? { mode: 'style' }
31
38
  );
32
39
  const interaction = getLegendInteraction() ?? createLegendInteraction();
33
40
 
@@ -37,7 +44,7 @@
37
44
  if (seeded.has(item.name)) continue;
38
45
  seeded.add(item.name);
39
46
  if (item.disabled) {
40
- interaction.disabledSeries.set(item.name, disabledStyle);
47
+ interaction.disabledSeries.set(item.name, disabledAction);
41
48
  }
42
49
  }
43
50
  });
@@ -47,7 +54,7 @@
47
54
  if (interaction.disabledSeries.has(item.name)) {
48
55
  interaction.disabledSeries.delete(item.name);
49
56
  } else {
50
- interaction.disabledSeries.set(item.name, disabledStyle);
57
+ interaction.disabledSeries.set(item.name, disabledAction);
51
58
  }
52
59
  }
53
60
 
@@ -142,8 +149,14 @@
142
149
  </script>
143
150
 
144
151
  {#snippet discreteItem(item: LegendItem, i: number)}
145
- {@const disabledStyle = interaction.disabledSeries.get(item.name)}
146
- {@const isDisabled = disabledStyle != null}
152
+ {@const action = interaction.disabledSeries.get(item.name)}
153
+ {@const isDisabled = action != null}
154
+ {@const omitMode = disabledAction.mode === 'omit'}
155
+ {@const disabledStyle = isDisabled
156
+ ? omitMode
157
+ ? { opacity: cfg.legend.disabledOpacity }
158
+ : resolveDisabledStyle(action, cfg.legend.disabledOpacity)
159
+ : undefined}
147
160
  {@const color =
148
161
  disabledStyle?.fill ??
149
162
  disabledStyle?.stroke?.stroke ??
@@ -167,7 +180,7 @@
167
180
  : {}}
168
181
  >
169
182
  {#if useBadge}
170
- <DiscreteBadgeItem {item} {color} disabled={isDisabled} {showIcon} />
183
+ <DiscreteBadgeItem {item} {color} disabled={isDisabled} {showIcon} {omitMode} />
171
184
  {:else}
172
185
  <DiscreteSwatchItem {item} {color} />
173
186
  {/if}
@@ -1,6 +1,7 @@
1
- import type { DiscreteLegendSection } from '../../types/layout/legend';
1
+ import type { DiscreteLegendSection, LegendItemsLayout } from '../../types/layout/legend';
2
2
  type $$ComponentProps = {
3
3
  section: DiscreteLegendSection;
4
+ defaultItemsLayout?: LegendItemsLayout;
4
5
  };
5
6
  declare const DiscreteSection: import("svelte").Component<$$ComponentProps, {}, "">;
6
7
  type DiscreteSection = ReturnType<typeof DiscreteSection>;
@@ -1,13 +1,18 @@
1
1
  <script lang="ts">
2
- import type { LegendSection } from '../../types/layout/legend';
2
+ import type { LegendItemsLayout, LegendSection } from '../../types/layout/legend';
3
3
  import DiscreteSection from './DiscreteSection.svelte';
4
4
  import ContinuousSection from './ContinuousSection.svelte';
5
5
 
6
6
  /**
7
7
  * Renders one or more legend sections side by side, dispatching each to the
8
8
  * matching section renderer. Discrete and continuous sections can be mixed.
9
+ * A lone discrete section's items default to horizontal flow; vertical once
10
+ * a second section joins. A section's own `layout` always wins.
9
11
  */
10
12
  let { sections }: { sections: LegendSection[] } = $props();
13
+ const defaultItemsLayout = $derived<LegendItemsLayout>(
14
+ sections.length === 1 ? { type: 'horizontal' } : { type: 'vertical' }
15
+ );
11
16
  </script>
12
17
 
13
18
  <div class="dct-legend">
@@ -17,7 +22,7 @@
17
22
  <div class="dct-legend__title">{section.title}</div>
18
23
  {/if}
19
24
  {#if section.type === 'discrete'}
20
- <DiscreteSection {section} />
25
+ <DiscreteSection {section} {defaultItemsLayout} />
21
26
  {:else if section.type === 'continuous'}
22
27
  <ContinuousSection {section} />
23
28
  {/if}
@@ -5,8 +5,9 @@
5
5
  /**
6
6
  * Renders one `'bar'` marker — `role: 'segment'` is a visible bar
7
7
  * (`<BarX>`/`<BarY>`, per `isHorizontal`); `role: 'hitArea'` is an
8
- * invisible hit region for one category, resolved against the live scale
9
- * via `usePlot()`.
8
+ * invisible hit region for one category; `role: 'highlight'` is a visible,
9
+ * non-interactive area highlight. The latter two resolve their geometry
10
+ * against the live scale via `usePlot()`.
10
11
  */
11
12
  let { marker }: { marker: BarMarkerConfig } = $props();
12
13
 
@@ -66,27 +67,65 @@
66
67
  {@const bandwidth = categoryScale.fn.bandwidth()}
67
68
  {@const bandStart = Number(categoryScale.fn(marker.category as never) ?? 0)}
68
69
  {@const [valueMin, valueMax] = valueExtentFor(marker.isHorizontal)}
69
- <!-- svelte-ignore a11y_no_static_element_interactions -->
70
- {#if marker.isHorizontal}
71
- <rect
72
- x={valueMin}
73
- y={bandStart}
74
- width={Math.max(0, valueMax - valueMin)}
75
- height={bandwidth}
76
- fill="transparent"
77
- onpointerenter={marker.onpointerenter}
78
- onpointerleave={marker.onpointerleave}
79
- />
70
+ {#if marker.role === 'hitArea'}
71
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
72
+ {#if marker.isHorizontal}
73
+ <rect
74
+ x={valueMin}
75
+ y={bandStart}
76
+ width={Math.max(0, valueMax - valueMin)}
77
+ height={bandwidth}
78
+ fill="transparent"
79
+ onpointerenter={marker.onpointerenter}
80
+ onpointerleave={marker.onpointerleave}
81
+ />
82
+ {:else}
83
+ <rect
84
+ x={bandStart}
85
+ y={valueMin}
86
+ width={bandwidth}
87
+ height={Math.max(0, valueMax - valueMin)}
88
+ fill="transparent"
89
+ onpointerenter={marker.onpointerenter}
90
+ onpointerleave={marker.onpointerleave}
91
+ />
92
+ {/if}
80
93
  {:else}
81
- <rect
82
- x={bandStart}
83
- y={valueMin}
84
- width={bandwidth}
85
- height={Math.max(0, valueMax - valueMin)}
86
- fill="transparent"
87
- onpointerenter={marker.onpointerenter}
88
- onpointerleave={marker.onpointerleave}
89
- />
94
+ {@const thickness = bandwidth + 2}
95
+ {@const center = bandStart + bandwidth / 2}
96
+ {#if marker.isHorizontal}
97
+ <rect
98
+ class="hover-area"
99
+ x={valueMin}
100
+ y={center - thickness / 2}
101
+ width={Math.max(0, valueMax - valueMin)}
102
+ height={thickness}
103
+ fill={marker.style?.fill}
104
+ fill-opacity={marker.style?.fillOpacity}
105
+ stroke={marker.style?.stroke}
106
+ stroke-width={marker.style?.strokeWidth}
107
+ stroke-opacity={marker.style?.strokeOpacity}
108
+ stroke-dasharray={marker.style?.strokeDasharray}
109
+ rx={marker.style?.borderRadius}
110
+ pointer-events="none"
111
+ />
112
+ {:else}
113
+ <rect
114
+ class="hover-area"
115
+ x={center - thickness / 2}
116
+ y={valueMin}
117
+ width={thickness}
118
+ height={Math.max(0, valueMax - valueMin)}
119
+ fill={marker.style?.fill}
120
+ fill-opacity={marker.style?.fillOpacity}
121
+ stroke={marker.style?.stroke}
122
+ stroke-width={marker.style?.strokeWidth}
123
+ stroke-opacity={marker.style?.strokeOpacity}
124
+ stroke-dasharray={marker.style?.strokeDasharray}
125
+ ry={marker.style?.borderRadius}
126
+ pointer-events="none"
127
+ />
128
+ {/if}
90
129
  {/if}
91
130
  {/if}
92
131
  {/if}
@@ -2,6 +2,7 @@
2
2
  import DotMarker from './DotMarker.svelte';
3
3
  import TextMarker from './TextMarker.svelte';
4
4
  import RuleMarker from './RuleMarker.svelte';
5
+ import BarMarker from './BarMarker.svelte';
5
6
  import { getConfiguration } from '../configuration/config.svelte';
6
7
  import type { MarkerProps } from '../types/markers/props';
7
8
  import type { HoverMarkerConfig } from '../types/markers/common';
@@ -22,10 +23,19 @@
22
23
  * pattern `BarMarker`/`RuleMarker` follow. `activePoint`/
23
24
  * `impliesCrosshairX`/`impliesCrosshairY` are all optional and default to
24
25
  * "no crosshair".
26
+ *
27
+ * `areaX`/`areaY` are a separate, always-explicit opt-in (no implied
28
+ * default) for a thickened area highlight in place of the thin crosshair —
29
+ * see `HoverMarkerConfig`'s own doc comment. Rendered via `BarMarker`'s
30
+ * `role: 'highlight'`, so it renders nothing on an axis without a band
31
+ * scale (e.g. a line/scatter plot's continuous axis).
25
32
  */
26
33
  interface Props extends MarkerProps<HoverDisplayPoint> {
27
34
  /** Narrowed to just the fields this component reads — `HoverMarkerConfig` also carries routing/scoping fields (`series`/`scope`/`strategy`/`sync`) it has no use for. */
28
- marker: Pick<HoverMarkerConfig, 'ruleX' | 'ruleY' | 'showLabels' | 'format' | 'ruleStyle' | 'dotStyle' | 'fontStyle'>;
35
+ marker: Pick<
36
+ HoverMarkerConfig,
37
+ 'ruleX' | 'ruleY' | 'areaX' | 'areaY' | 'showLabels' | 'format' | 'ruleStyle' | 'areaStyle' | 'dotStyle' | 'fontStyle'
38
+ >;
29
39
  activePoint?: HoverDisplayPoint | null;
30
40
  impliesCrosshairX?: boolean;
31
41
  impliesCrosshairY?: boolean;
@@ -46,9 +56,13 @@
46
56
  const showCrosshairY = $derived(marker.ruleY !== false && (impliesCrosshairY || marker.ruleY === true));
47
57
  const ruleX = $derived(showCrosshairX ? (activePoint?.x ?? null) : null);
48
58
  const ruleY = $derived(showCrosshairY ? (activePoint?.y ?? null) : null);
59
+ // areaX/areaY have no implied default — always off unless explicitly set.
60
+ const areaX = $derived(marker.areaX === true ? (activePoint?.x ?? null) : null);
61
+ const areaY = $derived(marker.areaY === true ? (activePoint?.y ?? null) : null);
49
62
  const showLabels = $derived(marker.showLabels ?? true);
50
63
  const format = $derived(marker.format);
51
64
  const ruleStyle = $derived(marker.ruleStyle);
65
+ const areaStyle = $derived({ ...cfg.hover.area, ...marker.areaStyle });
52
66
  const dotStyle = $derived(marker.dotStyle);
53
67
  const fontStyle = $derived(marker.fontStyle);
54
68
 
@@ -93,6 +107,32 @@
93
107
  }
94
108
  </script>
95
109
 
110
+ {#if areaX != null}
111
+ <BarMarker
112
+ marker={{
113
+ type: 'bar',
114
+ role: 'highlight',
115
+ isHorizontal: false,
116
+ data: [],
117
+ category: areaX,
118
+ style: areaStyle,
119
+ }}
120
+ />
121
+ {/if}
122
+
123
+ {#if areaY != null}
124
+ <BarMarker
125
+ marker={{
126
+ type: 'bar',
127
+ role: 'highlight',
128
+ isHorizontal: true,
129
+ data: [],
130
+ category: areaY,
131
+ style: areaStyle,
132
+ }}
133
+ />
134
+ {/if}
135
+
96
136
  {#if ruleX != null}
97
137
  <RuleMarker axis="x" value={ruleX} style={{ ...cfg.hover.rule, ...ruleStyle }} />
98
138
  {/if}
@@ -16,10 +16,16 @@ import type { HoverDisplayPoint } from '../types/layout/tooltip';
16
16
  * pattern `BarMarker`/`RuleMarker` follow. `activePoint`/
17
17
  * `impliesCrosshairX`/`impliesCrosshairY` are all optional and default to
18
18
  * "no crosshair".
19
+ *
20
+ * `areaX`/`areaY` are a separate, always-explicit opt-in (no implied
21
+ * default) for a thickened area highlight in place of the thin crosshair —
22
+ * see `HoverMarkerConfig`'s own doc comment. Rendered via `BarMarker`'s
23
+ * `role: 'highlight'`, so it renders nothing on an axis without a band
24
+ * scale (e.g. a line/scatter plot's continuous axis).
19
25
  */
20
26
  interface Props extends MarkerProps<HoverDisplayPoint> {
21
27
  /** Narrowed to just the fields this component reads — `HoverMarkerConfig` also carries routing/scoping fields (`series`/`scope`/`strategy`/`sync`) it has no use for. */
22
- marker: Pick<HoverMarkerConfig, 'ruleX' | 'ruleY' | 'showLabels' | 'format' | 'ruleStyle' | 'dotStyle' | 'fontStyle'>;
28
+ marker: Pick<HoverMarkerConfig, 'ruleX' | 'ruleY' | 'areaX' | 'areaY' | 'showLabels' | 'format' | 'ruleStyle' | 'areaStyle' | 'dotStyle' | 'fontStyle'>;
23
29
  activePoint?: HoverDisplayPoint | null;
24
30
  impliesCrosshairX?: boolean;
25
31
  impliesCrosshairY?: boolean;
@@ -2,7 +2,12 @@
2
2
  import { paletteColor } from '../../utils/color';
3
3
  import { buildSeries } from '../../utils/grouping';
4
4
  import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
5
- import { disabledFillOverride } from '../utils/legendDisabled';
5
+ import {
6
+ disabledFillOverride,
7
+ resolveEffectiveDisabledStyle,
8
+ omitDisabledSeries,
9
+ buildSeriesColorIndex
10
+ } from '../utils/legendDisabled';
6
11
  import { resolveAccessor } from '../utils/accessors';
7
12
  import { getConfiguration } from '../../configuration/config.svelte';
8
13
  import {
@@ -71,7 +76,15 @@
71
76
  });
72
77
 
73
78
  const cfg = $derived(getConfiguration());
74
- const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
79
+ const legendInteraction = $derived(getLegendInteraction());
80
+ // Unfiltered series list, for stable per-name color indexing.
81
+ const fullSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
82
+ const colorIndex = $derived(buildSeriesColorIndex(fullSeries));
83
+ function colorForSeries(name: string): string {
84
+ return paletteColor(colorIndex.get(name) ?? 0, cfg.palette);
85
+ }
86
+ // Drops series toggled off with `disabled: { mode: 'omit' }`.
87
+ const resolvedSeries = $derived(omitDisabledSeries(fullSeries, legendInteraction));
75
88
  const groupedSeries = $derived(
76
89
  // connect: false — bars are discrete; there's no visual gap between
77
90
  // adjacent categories to bridge (see buildGroupedSeries's own doc).
@@ -104,7 +117,7 @@
104
117
  buildScopedMarkerGroups<TData, Series<TData>, BarSegmentStyle>({
105
118
  groupedSeries,
106
119
  segments,
107
- colorFor: (_series, seriesIndex) => paletteColor(seriesIndex, cfg.palette),
120
+ colorFor: (series) => colorForSeries(series.name),
108
121
  segmentColorFor: (style, defaultColor) => style?.fill ?? defaultColor
109
122
  })
110
123
  );
@@ -181,7 +194,6 @@
181
194
  // which drive tick/domain computation and the Pointer's hit-testing) —
182
195
  // `row` (see BasePlotLayout/HoverPoint) is what lets the template recover the
183
196
  // semantic category regardless of which axis it landed on.
184
- const legendInteraction = $derived(getLegendInteraction());
185
197
  const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
186
198
 
187
199
  // A value label hovered while `styles.values.hoverIsolate` is on dims
@@ -191,18 +203,19 @@
191
203
  let hoverIsolatedSeries = $state<string | null>(null);
192
204
 
193
205
  function effectiveDisabledFor(name: string): LegendDisabledStyle | undefined {
194
- return (
195
- legendInteraction?.disabledSeries.get(name) ??
196
- (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
197
- ? { opacity: cfg.legend.disabledOpacity }
198
- : undefined)
206
+ return resolveEffectiveDisabledStyle(
207
+ name,
208
+ legendInteraction,
209
+ cfg.legend.disabledOpacity,
210
+ hoverIsolate,
211
+ hoverIsolatedSeries
199
212
  );
200
213
  }
201
214
 
202
215
  // A legend-disabled series is dimmed on the bar itself, but never surfaces
203
216
  // in hover/tooltip — its points are simply absent from the candidate list.
204
217
  const hoverPoints = $derived(
205
- buildHoverPoints(resolvedSeries, isHorizontal, barLayout, cfg.palette).filter(
218
+ buildHoverPoints(resolvedSeries, isHorizontal, barLayout, colorForSeries).filter(
206
219
  (p) => !legendInteraction?.disabledSeries.has(p.series!)
207
220
  )
208
221
  );
@@ -294,7 +307,7 @@
294
307
  barLayout,
295
308
  isHorizontal,
296
309
  hoverPoints,
297
- palette: cfg.palette,
310
+ colorFor: colorForSeries,
298
311
  disabledFillFor: (name) => disabledFillOverride(effectiveDisabledFor(name)),
299
312
  active: hover.active,
300
313
  setHoverMatch,
@@ -25,7 +25,7 @@ export declare function buildBarMarkers<TData extends Record<string, unknown>>(a
25
25
  barLayout: BarLayout<TData>;
26
26
  isHorizontal: boolean;
27
27
  hoverPoints: HoverPoint<TData>[];
28
- palette: string[];
28
+ colorFor: (seriesName: string) => string;
29
29
  disabledFillFor: (seriesName: string) => {
30
30
  fill?: string;
31
31
  fillOpacity?: number;
@@ -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
  import { isGapValue, resolveGapAreaStyle } from '../utils/gaps';
5
4
  const GAP_KINDS = ['start', 'middle', 'end'];
@@ -19,13 +18,13 @@ const GAP_KINDS = ['start', 'middle', 'end'];
19
18
  * where the marker actually renders.
20
19
  */
21
20
  export function buildBarMarkers(args) {
22
- const { groupedSeries, barLayout, isHorizontal, hoverPoints, palette, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
21
+ const { groupedSeries, barLayout, isHorizontal, hoverPoints, colorFor, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
23
22
  const gapDefaults = getConfiguration().bar.gap;
24
23
  const segmentMarkers = [];
25
24
  groupedSeries.forEach((group, seriesIndex) => {
26
25
  const xFn = resolveAccessor(group.series.x);
27
26
  const yFn = resolveAccessor(group.series.y);
28
- const defaultColor = paletteColor(seriesIndex, palette);
27
+ const defaultColor = colorFor(group.series.name);
29
28
  const disabledFill = disabledFillFor(group.series.name);
30
29
  // Places one series' bar(s) — real or gap-fill — using whichever
31
30
  // layout mode (stacked baseline, or flat baseline + grouped insets) is
@@ -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
  });
@@ -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
@@ -1,3 +1,30 @@
1
+ /** Resolves a {@link LegendDisabledAction} into the style a mark should render with — `'omit'` resolves to fully transparent. */
2
+ export function resolveDisabledStyle(action, fallbackOpacity) {
3
+ if (!action)
4
+ return undefined;
5
+ if (action.mode === 'omit')
6
+ return { opacity: 0 };
7
+ return action.style ?? { opacity: fallbackOpacity };
8
+ }
9
+ /** Drops series toggled off with `disabled: { mode: 'omit' }` from a series list. */
10
+ export function omitDisabledSeries(series, legendInteraction) {
11
+ if (!legendInteraction)
12
+ return series;
13
+ return series.filter((s) => legendInteraction.disabledSeries.get(s.name)?.mode !== 'omit');
14
+ }
15
+ /** Builds a per-series-name → palette index from the full series list, before {@link omitDisabledSeries} filters it. */
16
+ export function buildSeriesColorIndex(series) {
17
+ const index = new Map();
18
+ series.forEach((s, i) => index.set(s.name, i));
19
+ return index;
20
+ }
21
+ /** Resolves the disabled style for a series by name: legend-off state, falling back to hover-isolate dim. */
22
+ export function resolveEffectiveDisabledStyle(name, legendInteraction, disabledOpacity, hoverIsolate, hoverIsolatedSeries) {
23
+ return (resolveDisabledStyle(legendInteraction?.disabledSeries.get(name), disabledOpacity) ??
24
+ (hoverIsolate && hoverIsolatedSeries && hoverIsolatedSeries !== name
25
+ ? { opacity: disabledOpacity }
26
+ : undefined));
27
+ }
1
28
  /**
2
29
  * The three per-mark override layers a legend "disabled" state can apply,
3
30
  * derived once from a {@link LegendDisabledStyle}. Each is `undefined` when
@@ -37,7 +37,7 @@ export type ChartConfig = {
37
37
  gap: string;
38
38
  /** swatch square size */
39
39
  swatchSize: string;
40
- /** Fallback opacity for a legend-disabled series/value when a section doesn't set its own `disabledStyle`. */
40
+ /** Fallback opacity for a legend-disabled series/value when a section doesn't set its own style. */
41
41
  disabledOpacity: number;
42
42
  /** Prev/next arrows shown when a `layout: { type: 'horizontal' }` discrete section overflows `maxRows`. */
43
43
  navButton: {
@@ -160,7 +160,11 @@ export type ChartConfig = {
160
160
  activeColor: string;
161
161
  thickness: number;
162
162
  };
163
- /** In-SVG hover highlight: crosshair rule, dot + value label, and geo feature restyle. */
163
+ /**
164
+ * In-SVG hover highlight: crosshair rule, dot + value label, and the area
165
+ * highlight — a restyled feature on `GeoPlot`, or an `areaX`/`areaY` band
166
+ * on an axis-based plot's band scale.
167
+ */
164
168
  hover: {
165
169
  rule: StrokeStyle;
166
170
  dot: DotStyle;
@@ -17,6 +17,13 @@ export type LegendDisabledStyle = {
17
17
  fill?: string;
18
18
  fillOpacity?: number;
19
19
  };
20
+ /** What a legend toggle does to a series once it's off: `'style'` dims it in place, `'omit'` removes it from the plot entirely. */
21
+ export type LegendDisabledAction = {
22
+ mode: 'style';
23
+ style?: LegendDisabledStyle;
24
+ } | {
25
+ mode: 'omit';
26
+ };
20
27
  export type LegendItem = {
21
28
  name: string;
22
29
  label?: string;
@@ -28,11 +35,9 @@ export type LegendItem = {
28
35
  disabled?: boolean;
29
36
  };
30
37
  /**
31
- * How a discrete section's items flow. `'vertical'` (the default) stacks them
32
- * in a `columns`-wide grid, as it always has. `'horizontal'` flows them left
33
- * to right, wrapping to new rows as needed — and once wrapped rows exceed
34
- * `maxRows` (default 1), the section paginates instead of growing taller,
35
- * surfacing prev/next arrows to page through the rest.
38
+ * How a discrete section's items flow. `'vertical'` stacks them in a
39
+ * `columns`-wide grid; `'horizontal'` flows them left to right, paginating
40
+ * past `maxRows` (default 1) with prev/next arrows.
36
41
  */
37
42
  export type LegendItemsLayout = {
38
43
  type: 'vertical';
@@ -75,8 +80,8 @@ export type DiscreteInteractiveConfig = {
75
80
  enabled?: boolean;
76
81
  /** Item look while `enabled`. Defaults to `'badge'`. */
77
82
  legendStyle?: LegendInteractiveStyle;
78
- /** Style applied to a toggled-off item. Defaults to `{ opacity: cfg.legend.disabledOpacity }`. */
79
- disabledStyle?: LegendDisabledStyle;
83
+ /** What happens to a toggled-off item. Defaults to `{ mode: 'style' }`. */
84
+ disabled?: LegendDisabledAction;
80
85
  /** Shows the symbol icon inside a `'badge'` pill. Defaults to `true`; ignored by `'basic'`, which always shows it. */
81
86
  showIcon?: boolean;
82
87
  };
@@ -85,7 +90,7 @@ export type DiscreteLegendSection = {
85
90
  title?: string;
86
91
  items: LegendItem[];
87
92
  columns?: number;
88
- /** Item flow direction. Defaults to `{ type: 'vertical' }` (unchanged prior behaviour). */
93
+ /** Item flow direction. Defaults to horizontal for a lone section, vertical once there's more than one. */
89
94
  layout?: LegendItemsLayout;
90
95
  interactive?: DiscreteInteractiveConfig;
91
96
  navButton?: LegendNavSnippet;
@@ -129,8 +134,8 @@ export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
129
134
  * own private store via `createLegendInteraction`.
130
135
  */
131
136
  export type LegendInteractionStore = {
132
- /** Legend item name → resolved style to apply. Presence in the map means "disabled". */
133
- disabledSeries: SvelteMap<string, LegendDisabledStyle>;
137
+ /** Legend item name → action to apply. Presence in the map means "disabled". */
138
+ disabledSeries: SvelteMap<string, LegendDisabledAction>;
134
139
  /**
135
140
  * The active drag state of the (first) interactive continuous section, or
136
141
  * `null` when none is interactive or the user hasn't narrowed it yet (full
@@ -22,9 +22,18 @@ export type HoverMarkerConfig = {
22
22
  sync?: boolean;
23
23
  ruleX?: boolean;
24
24
  ruleY?: boolean;
25
+ /**
26
+ * Thickened area in place of a thin crosshair — sized to the hovered
27
+ * bar/cell's own width, spanning the full opposite axis. Off by default;
28
+ * meaningful only on a band (categorical) scale, so it renders nothing on
29
+ * a line/scatter plot's continuous axis.
30
+ */
31
+ areaX?: boolean;
32
+ areaY?: boolean;
25
33
  showLabels?: boolean;
26
34
  format?: (point: HoverDisplayPoint) => string;
27
35
  ruleStyle?: StrokeStyle;
36
+ areaStyle?: AreaStyle;
28
37
  dotStyle?: DotStyle;
29
38
  fontStyle?: FontStyle;
30
39
  };
@@ -40,11 +49,14 @@ export type LineMarkerConfig = {
40
49
  * A single bar mark. `role: 'segment'` is a visible bar
41
50
  * (`x`/`y`/`x1`/`x2`/`y1`/`y2` map to svelteplot's `<BarX>`/`<BarY>` per
42
51
  * `isHorizontal`); `role: 'hitArea'` is an invisible per-category hit region
43
- * for hover, resolved against the live scale at render time.
52
+ * for hover, resolved against the live scale at render time; `role:
53
+ * 'highlight'` is `HoverMarkerConfig`'s `areaX`/`areaY` highlight — a
54
+ * visible, non-interactive rect centered on `category`, spanning the full
55
+ * opposite axis, sized to the category band's own width plus 2px.
44
56
  */
45
57
  export type BarMarkerConfig = {
46
58
  type: 'bar';
47
- role: 'segment' | 'hitArea';
59
+ role: 'segment' | 'hitArea' | 'highlight';
48
60
  isHorizontal: boolean;
49
61
  data: Record<string, unknown>[];
50
62
  x?: (d: any) => AxisValue;
@@ -23,6 +23,40 @@ export function paletteColor(index, palette) {
23
23
  return 'currentColor';
24
24
  return palette[index % palette.length];
25
25
  }
26
+ function clamp01(v) {
27
+ return Math.max(0, Math.min(1, v));
28
+ }
29
+ function linearToSrgb8(c) {
30
+ const s = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
31
+ return clamp01(s) * 255;
32
+ }
33
+ /**
34
+ * Parses `oklch(L C H)` directly instead of round-tripping through the
35
+ * browser's computed style: Chromium/Firefox serialize computed oklch()
36
+ * values back as oklch() (not rgb()), so the generic browser-fallback
37
+ * below sees an unchanged string on the second pass and its
38
+ * infinite-loop guard mistakes that for a parse failure.
39
+ */
40
+ function parseOklch(color) {
41
+ const match = /^oklch\(\s*([\d.]+)(%)?\s+([\d.]+)\s+([\d.]+)/i.exec(color.trim());
42
+ if (!match)
43
+ return null;
44
+ const L = match[2] ? Number(match[1]) / 100 : Number(match[1]);
45
+ const C = Number(match[3]);
46
+ const H = (Number(match[4]) * Math.PI) / 180;
47
+ const a = C * Math.cos(H);
48
+ const b = C * Math.sin(H);
49
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
50
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
51
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
52
+ const l = l_ ** 3;
53
+ const m = m_ ** 3;
54
+ const s = s_ ** 3;
55
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
56
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
57
+ const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
58
+ return [linearToSrgb8(r), linearToSrgb8(g), linearToSrgb8(bl)];
59
+ }
26
60
  function parseRgb(color) {
27
61
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
28
62
  if (hex) {
@@ -33,6 +67,9 @@ function parseRgb(color) {
33
67
  const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
34
68
  if (rgb)
35
69
  return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
70
+ const oklch = parseOklch(color);
71
+ if (oklch)
72
+ return oklch;
36
73
  // Named colors, hsl(), and any other CSS color syntax: resolved to
37
74
  // `rgb(...)` via the browser's computed style, then reparsed.
38
75
  if (!isBrowser)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"