@fundar/data-chart-telling 0.0.50 → 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.
@@ -115,7 +115,12 @@ export const DEFAULT_CONFIG = {
115
115
  connectorWidth: 0.5
116
116
  }
117
117
  },
118
- bar: { gap: { fillOpacity: 0.5 } },
118
+ bar: {
119
+ gap: { fillOpacity: 0.5 },
120
+ seriesPadding: 0.2,
121
+ paddingInner: 0.15,
122
+ paddingOuter: 0.15
123
+ },
119
124
  pyramid: { gap: { fillOpacity: 0.5 } },
120
125
  scatter: { dotRadius: 5 },
121
126
  timeline: { axisColor: 'currentColor', activeColor: '#e6580d', thickness: 60 },
@@ -147,6 +147,9 @@ export declare const THEMES: {
147
147
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
148
148
  borderRadius?: number | undefined;
149
149
  } | undefined;
150
+ seriesPadding?: number | undefined;
151
+ paddingInner?: number | undefined;
152
+ paddingOuter?: number | undefined;
150
153
  } | undefined;
151
154
  pyramid?: {
152
155
  gap?: {
@@ -372,6 +375,9 @@ export declare const THEMES: {
372
375
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
373
376
  borderRadius?: number | undefined;
374
377
  } | undefined;
378
+ seriesPadding?: number | undefined;
379
+ paddingInner?: number | undefined;
380
+ paddingOuter?: number | undefined;
375
381
  } | undefined;
376
382
  pyramid?: {
377
383
  gap?: {
@@ -597,6 +603,9 @@ export declare const THEMES: {
597
603
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
598
604
  borderRadius?: number | undefined;
599
605
  } | undefined;
606
+ seriesPadding?: number | undefined;
607
+ paddingInner?: number | undefined;
608
+ paddingOuter?: number | undefined;
600
609
  } | undefined;
601
610
  pyramid?: {
602
611
  gap?: {
@@ -822,6 +831,9 @@ export declare const THEMES: {
822
831
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
823
832
  borderRadius?: number | undefined;
824
833
  } | undefined;
834
+ seriesPadding?: number | undefined;
835
+ paddingInner?: number | undefined;
836
+ paddingOuter?: number | undefined;
825
837
  } | undefined;
826
838
  pyramid?: {
827
839
  gap?: {
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
- import { AxisX, AxisY, GridX, GridY } from 'svelteplot';
2
+ import { AxisX, AxisY, GridX, GridY, usePlot } from 'svelteplot';
3
3
  import { getConfiguration } from '../../configuration/config.svelte';
4
+ import { tickStep, ticksInRange, naturalTickCount } from './niceDomain';
4
5
  import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
5
6
 
6
7
  /**
@@ -28,15 +29,25 @@
28
29
  } = $props();
29
30
 
30
31
  const cfg = $derived(getConfiguration());
32
+ const plot = usePlot();
31
33
 
32
34
  const xValues = $derived(data.map(getX));
33
35
  const yValues = $derived(data.map(getY));
34
36
 
35
- function naturalTickCount(span: number, spacing: number, min: number): number {
36
- return Math.max(min, Math.round(span / spacing));
37
- }
38
-
39
- /** `[min, max]` of a pinned `scale.domain` (if numeric), else the extent of `values`. */
37
+ /**
38
+ * `[min, max]` of a pinned `scale.domain` (if numeric), else the extent of
39
+ * `values`. Deliberately *not* the live, fully-resolved scale off
40
+ * `usePlot()` (e.g. `plot.scales.x.domain`), even though that would be
41
+ * more accurate for a bar's implicit zero baseline or a stacked series'
42
+ * cumulative total: reading it reactively here — inside a `$derived` that
43
+ * itself feeds back into `<AxisX>`'s rendered tick content, which
44
+ * `BasePlotLayout`'s own auto-margin sizing measures — closes a
45
+ * live-scale ⇄ rendered-axis feedback loop that was observed to perturb
46
+ * margin *settling* (a captured-and-frozen `bind:margins` value gained a
47
+ * stray extra `left` entry it wasn't tracking before). `edgeInclusiveTicks`
48
+ * instead sidesteps needing this axis's own real extent at all, by only
49
+ * running when the *opposite* axis's resolved type says it's safe to.
50
+ */
40
51
  function axisExtent(scale: AxisBasedScalesConfig<TData>['x'], values: AxisValue[]): [number, number] | undefined {
41
52
  const domainNumbers = scale?.domain?.filter((d): d is number => typeof d === 'number');
42
53
  const source = domainNumbers && domainNumbers.length >= 2 ? domainNumbers : values.filter((v): v is number => typeof v === 'number');
@@ -46,9 +57,30 @@
46
57
  return hi > lo ? [lo, hi] : undefined;
47
58
  }
48
59
 
49
- /** Caps tick count to the axis's distinct value count, never above what fits the pixel span. Skipped once the caller sets tick density directly, or pins the domain. */
50
- function autoTickCount(scale: AxisBasedScalesConfig<TData>['x'], values: AxisValue[], span: number, spacing: number, min: number): number | undefined {
60
+ /** Resolved scale types with their own discrete tick semantics an axis paired with one of these plays the "index" role for the pair, so *this* axis is the plain magnitude/value one (see `autoTickCount`, `edgeInclusiveTicks`). */
61
+ const DISCRETE_SCALE_TYPES = new Set(['band', 'categorical', 'point', 'ordinal']);
62
+
63
+ /**
64
+ * Caps tick count to the axis's distinct value count, never above what
65
+ * fits the pixel span — meaningful for an index-like axis (don't show more
66
+ * ticks than there are actual data points), but not for a magnitude/value
67
+ * one, where "how many bars there are" has nothing to do with how many
68
+ * round numbers should label the axis (and can otherwise stop tick
69
+ * generation short of `nice`'s own widened domain edge — the same
70
+ * "opposite axis is discrete ⇒ this one's a value axis" signal
71
+ * `edgeInclusiveTicks` uses). Skipped once the caller sets tick density
72
+ * directly, or pins the domain.
73
+ */
74
+ function autoTickCount(
75
+ oppositeType: string | undefined,
76
+ scale: AxisBasedScalesConfig<TData>['x'],
77
+ values: AxisValue[],
78
+ span: number,
79
+ spacing: number,
80
+ min: number
81
+ ): number | undefined {
51
82
  if (scale?.ticks != null || scale?.tickSpacing != null || scale?.interval != null || scale?.domain != null) return undefined;
83
+ if (oppositeType != null && DISCRETE_SCALE_TYPES.has(oppositeType)) return undefined;
52
84
  const uniqueCount = new Set(values.map(String)).size;
53
85
  const natural = naturalTickCount(span, spacing, min);
54
86
  return uniqueCount >= 2 && uniqueCount < natural ? uniqueCount : undefined;
@@ -74,12 +106,56 @@
74
106
  const xSpan = $derived(Math.max(0, width - numericMargins.left - numericMargins.right));
75
107
  const ySpan = $derived(Math.max(0, height - numericMargins.top - numericMargins.bottom));
76
108
 
77
- const xTickCount = $derived(autoTickCount(scales?.x, xValues, xSpan, 80, 3));
78
- const yTickCount = $derived(autoTickCount(scales?.y, yValues, ySpan, 50, 2));
109
+ const xTickCount = $derived(autoTickCount(plot.scales.y.type, scales?.x, xValues, xSpan, 80, 3));
110
+ const yTickCount = $derived(autoTickCount(plot.scales.x.type, scales?.y, yValues, ySpan, 50, 2));
79
111
 
80
112
  const xTickInterval = $derived(integerTickInterval(scales?.x, xValues, xSpan, 80, 3));
81
113
  const yTickInterval = $derived(integerTickInterval(scales?.y, yValues, ySpan, 50, 2));
82
114
 
115
+ /**
116
+ * An axis's own natural "nice" ticks, plus the domain's exact endpoints
117
+ * whenever a natural tick doesn't already land on them — so the axis
118
+ * always labels the actual start/end of its real range, not just
119
+ * whichever round numbers happen to fall inside it (which, per
120
+ * `axisExtent`, may extend past the raw data — e.g. a bar's implicit zero
121
+ * baseline).
122
+ *
123
+ * Only meaningful for an axis that plays the "series ran from here to
124
+ * here" role — a time series' x, say — which is exactly the case where the
125
+ * *opposite* axis is itself continuous (both this codebase's line/scatter
126
+ * charts). When the opposite axis is discrete instead (a bar/pyramid's
127
+ * category axis), *this* axis is really the plain magnitude/value one —
128
+ * `84` isn't meaningfully "where the series ends" the way a last year is,
129
+ * so forcing it onto the axis just crowds a round `80`/`90` tick for no
130
+ * benefit. Skipped once the caller sets tick density/domain directly, same
131
+ * guard as `autoTickCount`, or the axis isn't a plain continuous numeric
132
+ * one (`integerTickInterval`'s domain check covers the same ground
133
+ * `axisExtent` needs here).
134
+ */
135
+ function edgeInclusiveTicks(
136
+ oppositeType: string | undefined,
137
+ scale: AxisBasedScalesConfig<TData>['x'],
138
+ values: AxisValue[],
139
+ span: number,
140
+ spacing: number,
141
+ min: number
142
+ ): number[] | undefined {
143
+ if (scale?.ticks != null || scale?.tickSpacing != null || scale?.interval != null || scale?.domain != null) return undefined;
144
+ if (oppositeType != null && DISCRETE_SCALE_TYPES.has(oppositeType)) return undefined;
145
+ if (scale?.type != null && scale.type !== 'linear' && scale.type !== 'auto') return undefined;
146
+ const extent = axisExtent(scale, values);
147
+ if (!extent) return undefined;
148
+ const [lo, hi] = extent;
149
+ const count = naturalTickCount(span, spacing, min);
150
+ const step = tickStep(lo, hi, count);
151
+ const base = ticksInRange(lo, hi, step);
152
+ const eps = step * 1e-6;
153
+ const withLo = base.length === 0 || base[0] - lo > eps ? [lo, ...base] : base;
154
+ return withLo.length === 0 || hi - withLo[withLo.length - 1] > eps ? [...withLo, hi] : withLo;
155
+ }
156
+
157
+ const xTicksExplicit = $derived(edgeInclusiveTicks(plot.scales.y.type, scales?.x, xValues, xSpan, 80, 3));
158
+
83
159
  /** Vertical Y-axis title placement, when `scales.y.labelOrientation === 'vertical'`. */
84
160
  const verticalYLabel = $derived.by(() => {
85
161
  if (!scales?.y?.label || scales.y.labelOrientation !== 'vertical') return null;
@@ -93,6 +169,7 @@
93
169
  </script>
94
170
 
95
171
  <AxisX
172
+ ticks={xTicksExplicit}
96
173
  tickCount={xTickCount}
97
174
  interval={xTickInterval}
98
175
  tickSize={scales?.x?.tickSize ?? cfg.axis.xTickSize}
@@ -3,6 +3,23 @@ import type { AxisValue, AxisScale } from '../../types/layout/scales';
3
3
  * Resolves an author-facing {@link AxisScale} into what svelteplot's
4
4
  * `<Plot x>`/`<Plot y>` accepts — strips `grid`/`seriesLayout`/
5
5
  * `seriesPadding`/a vertical `label`, since `ScalesLayout` renders those
6
- * itself.
6
+ * itself. Also defaults `nice: true` whenever the caller hasn't pinned a
7
+ * `domain`/`ticks`/`nice`/non-numeric `type` of their own and the axis's own
8
+ * values are plainly numeric ({@link isNumericAxis}), so an unpinned
9
+ * continuous axis rounds its domain outward to whole tick steps instead of
10
+ * stopping exactly at the data's own min/max.
11
+ *
12
+ * Deliberately *not* our own `[min, max]` padding: svelteplot computes the
13
+ * real domain from every mark's own channels — e.g. a bar's implicit zero
14
+ * baseline, or a stacked series' cumulative total — not just this axis's raw
15
+ * per-row values, which is all this function has to work with. `nice`'s
16
+ * domain-widening runs *after* that real domain is resolved, inside
17
+ * svelteplot itself, so it stays correct for every plot kind for free — at
18
+ * the cost of only widening up to the nearest round tick step, which can
19
+ * still land exactly on the data's own min/max rather than strictly beyond
20
+ * it (e.g. a value of exactly `52` needs no widening to reach the nice tick
21
+ * `52`). Recomputing the domain ourselves to close that gap would need each
22
+ * axis's true rendered extent (baseline/stack included), which only the
23
+ * calling plot kind knows — not attempted here.
7
24
  */
8
25
  export declare function buildScale<TData>(scale: AxisScale<TData> | undefined, rows: TData[], getAxisValue: (d: TData) => AxisValue): AxisScale<TData> | undefined;
@@ -23,15 +23,60 @@ function sortedScale(scale, rows, getAxisValue) {
23
23
  const domain = [...reps].sort(sort).map(getAxisValue);
24
24
  return { ...scale, domain };
25
25
  }
26
+ /** Scale types {@link buildScale}'s default `nice` only applies to — every other type has its own tick semantics (or isn't numeric at all). */
27
+ const CONTINUOUS_NUMERIC_TYPES = new Set(['auto', 'linear', 'pow', 'sqrt', 'log', 'symlog']);
28
+ /**
29
+ * Whether this axis's own values are plainly numeric — required before
30
+ * defaulting `nice: true`. Not just an optimization: svelteplot's own type
31
+ * *inference* treats a `nice` option as a hint to resolve the scale as
32
+ * `'linear'` (see its `autoScales.js`), so setting it on a scale whose real
33
+ * values are strings (a bar/pyramid's category axis) would force a linear
34
+ * scale onto categorical data and break the mark entirely.
35
+ */
36
+ function isNumericAxis(rows, getAxisValue) {
37
+ let numericSeen = 0;
38
+ for (const row of rows) {
39
+ const v = getAxisValue(row);
40
+ if (v == null)
41
+ continue;
42
+ if (typeof v !== 'number')
43
+ return false;
44
+ if (++numericSeen >= 2)
45
+ return true;
46
+ }
47
+ return numericSeen > 0;
48
+ }
26
49
  /**
27
50
  * Resolves an author-facing {@link AxisScale} into what svelteplot's
28
51
  * `<Plot x>`/`<Plot y>` accepts — strips `grid`/`seriesLayout`/
29
52
  * `seriesPadding`/a vertical `label`, since `ScalesLayout` renders those
30
- * itself.
53
+ * itself. Also defaults `nice: true` whenever the caller hasn't pinned a
54
+ * `domain`/`ticks`/`nice`/non-numeric `type` of their own and the axis's own
55
+ * values are plainly numeric ({@link isNumericAxis}), so an unpinned
56
+ * continuous axis rounds its domain outward to whole tick steps instead of
57
+ * stopping exactly at the data's own min/max.
58
+ *
59
+ * Deliberately *not* our own `[min, max]` padding: svelteplot computes the
60
+ * real domain from every mark's own channels — e.g. a bar's implicit zero
61
+ * baseline, or a stacked series' cumulative total — not just this axis's raw
62
+ * per-row values, which is all this function has to work with. `nice`'s
63
+ * domain-widening runs *after* that real domain is resolved, inside
64
+ * svelteplot itself, so it stays correct for every plot kind for free — at
65
+ * the cost of only widening up to the nearest round tick step, which can
66
+ * still land exactly on the data's own min/max rather than strictly beyond
67
+ * it (e.g. a value of exactly `52` needs no widening to reach the nice tick
68
+ * `52`). Recomputing the domain ourselves to close that gap would need each
69
+ * axis's true rendered extent (baseline/stack included), which only the
70
+ * calling plot kind knows — not attempted here.
31
71
  */
32
72
  export function buildScale(scale, rows, getAxisValue) {
33
73
  const sorted = sortedScale(scale, rows, getAxisValue);
34
- if (!sorted)
74
+ const wantsNice = scale?.domain == null &&
75
+ scale?.ticks == null &&
76
+ scale?.nice == null &&
77
+ (scale?.type == null || CONTINUOUS_NUMERIC_TYPES.has(scale.type)) &&
78
+ isNumericAxis(rows, getAxisValue);
79
+ if (!sorted && !wantsNice)
35
80
  return sorted;
36
81
  const out = { ...sorted };
37
82
  delete out.grid;
@@ -39,5 +84,7 @@ export function buildScale(scale, rows, getAxisValue) {
39
84
  delete out.seriesPadding;
40
85
  if (scale?.labelOrientation === 'vertical')
41
86
  delete out.label;
87
+ if (wantsNice)
88
+ out.nice = true;
42
89
  return out;
43
90
  }
@@ -0,0 +1,6 @@
1
+ /** Step between "nice" (1/2/5×10ⁿ) tick values for an approximate `count` over `[start, stop]`. */
2
+ export declare function tickStep(start: number, stop: number, count: number): number;
3
+ /** Multiples of `step` covering `[start, stop]`, inclusive of either endpoint when it lands exactly on one. */
4
+ export declare function ticksInRange(start: number, stop: number, step: number): number[];
5
+ /** Ticks-per-axis heuristic — used by `ScalesLayout` to pick a target count from an axis's actual pixel span. */
6
+ export declare function naturalTickCount(span: number, spacing: number, min: number): number;
@@ -0,0 +1,34 @@
1
+ // d3-array-compatible "nice" tick step/generation, reimplemented locally to
2
+ // avoid depending on internals svelteplot itself pulls in (not one of our
3
+ // declared dependencies) — see https://d3js.org/d3-array/ticks.
4
+ /** Step between "nice" (1/2/5×10ⁿ) tick values for an approximate `count` over `[start, stop]`. */
5
+ export function tickStep(start, stop, count) {
6
+ const e10 = Math.sqrt(50);
7
+ const e5 = Math.sqrt(10);
8
+ const e2 = Math.sqrt(2);
9
+ const step0 = Math.abs(stop - start) / Math.max(0, count);
10
+ let step1 = 10 ** Math.floor(Math.log10(step0));
11
+ const error = step0 / step1;
12
+ if (error >= e10)
13
+ step1 *= 10;
14
+ else if (error >= e5)
15
+ step1 *= 5;
16
+ else if (error >= e2)
17
+ step1 *= 2;
18
+ return step1;
19
+ }
20
+ /** Multiples of `step` covering `[start, stop]`, inclusive of either endpoint when it lands exactly on one. */
21
+ export function ticksInRange(start, stop, step) {
22
+ if (!(step > 0) || !Number.isFinite(step))
23
+ return [];
24
+ const lo = Math.ceil(start / step);
25
+ const hi = Math.floor(stop / step);
26
+ const out = [];
27
+ for (let i = lo; i <= hi; i++)
28
+ out.push(i * step);
29
+ return out;
30
+ }
31
+ /** Ticks-per-axis heuristic — used by `ScalesLayout` to pick a target count from an axis's actual pixel span. */
32
+ export function naturalTickCount(span, spacing, min) {
33
+ return Math.max(min, Math.round(span / spacing));
34
+ }
@@ -1,4 +1,4 @@
1
- import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeries, } from './utils';
1
+ import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeries } from './utils';
2
2
  /**
3
3
  * The plot-agnostic hover state machine shared by every plot. Given reactive
4
4
  * accessors for the current hover store, this facet's id, and the local points,
@@ -11,13 +11,15 @@ import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeri
11
11
  */
12
12
  export function createHoverController(args) {
13
13
  let cursor = $state(null);
14
- // svelteplot's `Pointer` hands back cloned rows (not the original
15
- // references), so a row's series can't be looked up by identity — find it
16
- // by value against the locally-known, already-tagged points instead.
17
- const seriesForRow = (row) => {
14
+ // Finds the matching hoverPoint for a hit row — by identity first, then
15
+ // by x/y value (for svelteplot's `Pointer`, which hands back clones).
16
+ const pointForRow = (row) => {
17
+ const byIdentity = args.points().find((p) => p.row === row);
18
+ if (byIdentity)
19
+ return byIdentity;
18
20
  const x = args.getX()(row);
19
21
  const y = args.getY()(row);
20
- return args.points().find((p) => eq(p.x, x) && eq(p.y, y))?.series;
22
+ return args.points().find((p) => eq(p.x, x) && eq(p.y, y));
21
23
  };
22
24
  // Points matched in THIS facet. Non-source facets only react when sync is
23
25
  // enabled. The strategy in the shared store determines how points are found.
@@ -34,7 +36,16 @@ export function createHoverController(args) {
34
36
  // Keeps `row` (not just the display fields) so plot-kind templates can
35
37
  // resolve a semantic value off the source row directly — e.g. a bar
36
38
  // plot's category, regardless of which visual axis it's drawn on.
37
- const highlight = $derived(matched.map(({ x, y, label, series, color, dx, dy, row }) => ({ x, y, label, series, color, dx, dy, row })));
39
+ const highlight = $derived(matched.map(({ x, y, label, series, color, dx, dy, row }) => ({
40
+ x,
41
+ y,
42
+ label,
43
+ series,
44
+ color,
45
+ dx,
46
+ dy,
47
+ row
48
+ })));
38
49
  /**
39
50
  * The specific matched point that was actually hovered — found by the
40
51
  * shared store's `activeSeries` (set from whichever row was hit), falling
@@ -82,9 +93,10 @@ export function createHoverController(args) {
82
93
  function onPointer(data) {
83
94
  const h = args.hover();
84
95
  if (data.length) {
85
- h.activeX = args.getX()(data[0]);
86
- h.activeY = args.getY()(data[0]);
87
- h.activeSeries = seriesForRow(data[0]) ?? null;
96
+ const point = pointForRow(data[0]);
97
+ h.activeX = point?.x ?? args.getX()(data[0]);
98
+ h.activeY = point?.y ?? args.getY()(data[0]);
99
+ h.activeSeries = point?.series ?? null;
88
100
  h.source = args.facetId();
89
101
  }
90
102
  else if (h.source === args.facetId()) {
@@ -148,6 +160,6 @@ export function createHoverController(args) {
148
160
  },
149
161
  get cursor() {
150
162
  return cursor;
151
- },
163
+ }
152
164
  };
153
165
  }
@@ -16,6 +16,9 @@ export function matchByStrategy(points, hover) {
16
16
  if (strategy === 'y') {
17
17
  return activeY == null ? [] : points.filter((p) => eq(p.y, activeY));
18
18
  }
19
+ if (strategy === 'series') {
20
+ return activeSeries == null ? [] : points.filter((p) => p.series === activeSeries);
21
+ }
19
22
  // 'punctual': exact XY match, respecting series identity when both sides carry it
20
23
  if (activeX == null || activeY == null)
21
24
  return [];
@@ -1,131 +1,153 @@
1
1
  <script lang="ts">
2
- import { BarX, BarY, usePlot } from 'svelteplot';
3
- import type { BarMarkerConfig } from '../types/markers/common';
2
+ import { BarX, BarY, usePlot } from 'svelteplot';
3
+ import type { BarMarkerConfig } from '../types/markers/common';
4
4
 
5
- /**
6
- * Renders one `'bar'` marker `role: 'segment'` is a visible bar
7
- * (`<BarX>`/`<BarY>`, per `isHorizontal`); `role: 'hitArea'` is an
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()`.
11
- */
12
- let { marker }: { marker: BarMarkerConfig } = $props();
5
+ /**
6
+ * Renders one `'bar'` marker: `role: 'segment'` (visible bar via
7
+ * `<BarX>`/`<BarY>`), `role: 'hitArea'` (invisible hover hit region for
8
+ * one segment), or `role: 'highlight'` (visible area highlight).
9
+ */
10
+ let { marker }: { marker: BarMarkerConfig } = $props();
13
11
 
14
- const plot = usePlot();
15
- function categoryScaleFor(isHorizontal: boolean) {
16
- return isHorizontal ? plot.scales.y : plot.scales.x;
17
- }
18
- function valueScaleFor(isHorizontal: boolean) {
19
- return isHorizontal ? plot.scales.x : plot.scales.y;
20
- }
21
- function valueExtentFor(isHorizontal: boolean): [number, number] {
22
- const range = valueScaleFor(isHorizontal)?.fn?.range?.();
23
- if (!range || range.length < 2) return [0, 0];
24
- const a = Number(range[0]);
25
- const b = Number(range[range.length - 1]);
26
- return [Math.min(a, b), Math.max(a, b)];
27
- }
12
+ const plot = usePlot();
13
+ function categoryScaleFor(isHorizontal: boolean) {
14
+ return isHorizontal ? plot.scales.y : plot.scales.x;
15
+ }
16
+ function valueScaleFor(isHorizontal: boolean) {
17
+ return isHorizontal ? plot.scales.x : plot.scales.y;
18
+ }
19
+ function valueExtentFor(isHorizontal: boolean): [number, number] {
20
+ const range = valueScaleFor(isHorizontal)?.fn?.range?.();
21
+ if (!range || range.length < 2) return [0, 0];
22
+ const a = Number(range[0]);
23
+ const b = Number(range[range.length - 1]);
24
+ return [Math.min(a, b), Math.max(a, b)];
25
+ }
26
+ // A `'hitArea'` marker's own value-axis slice, mapped to pixels.
27
+ function hitAreaValueRangeFor(
28
+ isHorizontal: boolean,
29
+ from: unknown,
30
+ to: unknown
31
+ ): [number, number] {
32
+ if (typeof from !== 'number' || typeof to !== 'number') return valueExtentFor(isHorizontal);
33
+ const scale = valueScaleFor(isHorizontal);
34
+ const a = Number(scale?.fn?.(from) ?? 0);
35
+ const b = Number(scale?.fn?.(to) ?? 0);
36
+ return [Math.min(a, b), Math.max(a, b)];
37
+ }
28
38
  </script>
29
39
 
30
40
  {#if marker.role === 'segment'}
31
- {#if marker.isHorizontal}
32
- <BarX
33
- data={marker.data}
34
- y={marker.y}
35
- x1={marker.x1}
36
- x2={marker.x2}
37
- insetTop={marker.insetTop}
38
- insetBottom={marker.insetBottom}
39
- fill={marker.style?.fill}
40
- fillOpacity={marker.style?.fillOpacity}
41
- stroke={marker.style?.stroke}
42
- strokeWidth={marker.style?.strokeWidth}
43
- strokeOpacity={marker.style?.strokeOpacity}
44
- strokeDasharray={marker.style?.strokeDasharray}
45
- borderRadius={marker.style?.borderRadius}
46
- />
47
- {:else}
48
- <BarY
49
- data={marker.data}
50
- x={marker.x}
51
- y1={marker.y1}
52
- y2={marker.y2}
53
- insetLeft={marker.insetLeft}
54
- insetRight={marker.insetRight}
55
- fill={marker.style?.fill}
56
- fillOpacity={marker.style?.fillOpacity}
57
- stroke={marker.style?.stroke}
58
- strokeWidth={marker.style?.strokeWidth}
59
- strokeOpacity={marker.style?.strokeOpacity}
60
- strokeDasharray={marker.style?.strokeDasharray}
61
- borderRadius={marker.style?.borderRadius}
62
- />
63
- {/if}
41
+ {@const categoryScale = categoryScaleFor(marker.isHorizontal)}
42
+ {@const bandwidth = categoryScale?.type === 'band' ? categoryScale.fn.bandwidth() : 0}
43
+ {#if marker.isHorizontal}
44
+ <BarX
45
+ data={marker.data}
46
+ y={marker.y}
47
+ x1={marker.x1}
48
+ x2={marker.x2}
49
+ insetTop={marker.insetTop != null ? marker.insetTop * bandwidth : undefined}
50
+ insetBottom={marker.insetBottom != null ? marker.insetBottom * bandwidth : undefined}
51
+ fill={marker.style?.fill}
52
+ fillOpacity={marker.style?.fillOpacity}
53
+ stroke={marker.style?.stroke}
54
+ strokeWidth={marker.style?.strokeWidth}
55
+ strokeOpacity={marker.style?.strokeOpacity}
56
+ strokeDasharray={marker.style?.strokeDasharray}
57
+ borderRadius={marker.style?.borderRadius}
58
+ />
59
+ {:else}
60
+ <BarY
61
+ data={marker.data}
62
+ x={marker.x}
63
+ y1={marker.y1}
64
+ y2={marker.y2}
65
+ insetLeft={marker.insetLeft != null ? marker.insetLeft * bandwidth : undefined}
66
+ insetRight={marker.insetRight != null ? marker.insetRight * bandwidth : undefined}
67
+ fill={marker.style?.fill}
68
+ fillOpacity={marker.style?.fillOpacity}
69
+ stroke={marker.style?.stroke}
70
+ strokeWidth={marker.style?.strokeWidth}
71
+ strokeOpacity={marker.style?.strokeOpacity}
72
+ strokeDasharray={marker.style?.strokeDasharray}
73
+ borderRadius={marker.style?.borderRadius}
74
+ />
75
+ {/if}
64
76
  {:else}
65
- {@const categoryScale = categoryScaleFor(marker.isHorizontal)}
66
- {#if categoryScale?.type === 'band'}
67
- {@const bandwidth = categoryScale.fn.bandwidth()}
68
- {@const bandStart = Number(categoryScale.fn(marker.category as never) ?? 0)}
69
- {@const [valueMin, valueMax] = valueExtentFor(marker.isHorizontal)}
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}
93
- {:else}
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}
129
- {/if}
130
- {/if}
77
+ {@const categoryScale = categoryScaleFor(marker.isHorizontal)}
78
+ {#if categoryScale?.type === 'band'}
79
+ {@const bandwidth = categoryScale.fn.bandwidth()}
80
+ {@const bandStart = Number(categoryScale.fn(marker.category as never) ?? 0)}
81
+ {#if marker.role === 'hitArea'}
82
+ {@const [rangeMin, rangeMax] = marker.isHorizontal
83
+ ? hitAreaValueRangeFor(marker.isHorizontal, marker.x1, marker.x2)
84
+ : hitAreaValueRangeFor(marker.isHorizontal, marker.y1, marker.y2)}
85
+ {@const insetLeft = (marker.insetLeft ?? 0) * bandwidth}
86
+ {@const insetRight = (marker.insetRight ?? 0) * bandwidth}
87
+ {@const insetTop = (marker.insetTop ?? 0) * bandwidth}
88
+ {@const insetBottom = (marker.insetBottom ?? 0) * bandwidth}
89
+ {#if marker.isHorizontal}
90
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
91
+ <rect
92
+ class="dct-hit-area"
93
+ x={rangeMin + insetLeft}
94
+ y={bandStart + insetBottom}
95
+ width={Math.max(0, rangeMax - rangeMin - insetLeft - insetRight)}
96
+ height={Math.max(0, bandwidth - insetTop - insetBottom)}
97
+ fill="transparent"
98
+ onpointerenter={marker.onpointerenter}
99
+ onpointerleave={marker.onpointerleave}
100
+ />
101
+ {:else}
102
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
103
+ <rect
104
+ class="dct-hit-area"
105
+ x={bandStart + insetLeft}
106
+ y={rangeMin + insetBottom}
107
+ width={Math.max(0, bandwidth - insetLeft - insetRight)}
108
+ height={Math.max(0, rangeMax - rangeMin - insetTop - insetBottom)}
109
+ fill="transparent"
110
+ onpointerenter={marker.onpointerenter}
111
+ onpointerleave={marker.onpointerleave}
112
+ />
113
+ {/if}
114
+ {:else}
115
+ {@const [valueMin, valueMax] = valueExtentFor(marker.isHorizontal)}
116
+ {@const thickness = bandwidth + 2}
117
+ {@const center = bandStart + bandwidth / 2}
118
+ {#if marker.isHorizontal}
119
+ <rect
120
+ class="hover-area"
121
+ x={valueMin}
122
+ y={center - thickness / 2}
123
+ width={Math.max(0, valueMax - valueMin)}
124
+ height={thickness}
125
+ fill={marker.style?.fill}
126
+ fill-opacity={marker.style?.fillOpacity}
127
+ stroke={marker.style?.stroke}
128
+ stroke-width={marker.style?.strokeWidth}
129
+ stroke-opacity={marker.style?.strokeOpacity}
130
+ stroke-dasharray={marker.style?.strokeDasharray}
131
+ rx={marker.style?.borderRadius}
132
+ pointer-events="none"
133
+ />
134
+ {:else}
135
+ <rect
136
+ class="hover-area"
137
+ x={center - thickness / 2}
138
+ y={valueMin}
139
+ width={thickness}
140
+ height={Math.max(0, valueMax - valueMin)}
141
+ fill={marker.style?.fill}
142
+ fill-opacity={marker.style?.fillOpacity}
143
+ stroke={marker.style?.stroke}
144
+ stroke-width={marker.style?.strokeWidth}
145
+ stroke-opacity={marker.style?.strokeOpacity}
146
+ stroke-dasharray={marker.style?.strokeDasharray}
147
+ ry={marker.style?.borderRadius}
148
+ pointer-events="none"
149
+ />
150
+ {/if}
151
+ {/if}
152
+ {/if}
131
153
  {/if}
@@ -158,14 +158,27 @@
158
158
  delete rest.type;
159
159
  return rest;
160
160
  }
161
- // The single, fully-resolved scales object BasePlotLayout reads for
162
- // everything (ticks, grid, sort, and the real svelteplot scale) — avoids
163
- // keeping a second, separately-patched copy of `scales.x`/`scales.y` in
164
- // sync with the one actually forwarded.
161
+ // Backs the category axis's `paddingInner`/`paddingOuter` the gap
162
+ // *between* category groups with the theme's `bar.paddingInner`/
163
+ // `paddingOuter` default, when the plot doesn't set its own (or the
164
+ // `padding` shorthand). BarPlot-only, unlike svelteplot's own hardcoded
165
+ // 0.15 fallback for every band scale.
166
+ function withDefaultPadding(scale?: AxisScale<TData>): AxisScale<TData> {
167
+ return {
168
+ ...scale,
169
+ paddingInner: scale?.paddingInner ?? scale?.padding ?? cfg.bar.paddingInner,
170
+ paddingOuter: scale?.paddingOuter ?? scale?.padding ?? cfg.bar.paddingOuter
171
+ };
172
+ }
173
+ // The single, fully-resolved scales object both BasePlotLayout (ticks,
174
+ // grid, sort, the real svelteplot scale) and `createBarLayout` (the
175
+ // grouped-bar-width estimate) read for everything — avoids keeping a
176
+ // second, separately-patched copy of `scales.x`/`scales.y` in sync with
177
+ // the one actually forwarded.
165
178
  const resolvedScales = $derived({
166
179
  ...scales,
167
- x: stripCategoricalType(scales.x),
168
- y: stripCategoricalType(scales.y)
180
+ x: stripCategoricalType(categoricalAxis === 'x' ? withDefaultPadding(scales.x) : scales.x),
181
+ y: stripCategoricalType(categoricalAxis === 'y' ? withDefaultPadding(scales.y) : scales.y)
169
182
  });
170
183
 
171
184
  // The accessor feeding each *visual* channel — swapped from the
@@ -179,7 +192,7 @@
179
192
  * rendering, value labels, and hover markers below. See `layout.svelte.ts`.
180
193
  */
181
194
  const barLayout = createBarLayout<TData>({
182
- scales: () => scales,
195
+ scales: () => resolvedScales,
183
196
  isHorizontal: () => isHorizontal,
184
197
  width: () => width,
185
198
  height: () => height,
@@ -233,8 +246,8 @@
233
246
  // (via svelteplot's `RectPath`) actually render that band, which is
234
247
  // enough to mismatch the neighbouring category once bands get thin
235
248
  // (many categories). The `role: 'hitArea'` bar markers `buildBarMarkers`
236
- // emits hit-test the category bands directly instead — see its own doc
237
- // comment.
249
+ // emits hit-test each series' own segment directly instead — see its
250
+ // own doc comment.
238
251
  pointMatching: 'contains' as const
239
252
  });
240
253
 
@@ -6,19 +6,9 @@ import type { BarMarkerConfig } from '../../types/markers/common';
6
6
  import type { BarLayout } from './layout.svelte';
7
7
  import type { DiscreteGapFill } from '../utils/gaps';
8
8
  /**
9
- * Every `'bar'` marker for one BarPlot render: one `role: 'segment'` marker
10
- * per series (stacked) or per visual segment (grouped/overlap), one
11
- * additional `role: 'segment'` marker per series per gap zone kind for any
12
- * row `styles.gaps` fills (distinctly styled, drawn at its interpolated
13
- * value — see `resolveDiscreteGapFills`), plus one `role: 'hitArea'` marker
14
- * per category, so a chart never gets segment markers without their hit
15
- * areas. A row whose value is missing and *not* filled by a gap zone is
16
- * skipped entirely — no bar drawn, rather than the broken zero/`NaN`
17
- * geometry a raw missing value would otherwise produce.
18
- *
19
- * `'hitArea'` geometry is left unresolved (just `category` + `data`) —
20
- * resolving it to pixels needs svelteplot's live scale, only available
21
- * where the marker actually renders.
9
+ * Every `'bar'` marker for one BarPlot render: visible `role: 'segment'`
10
+ * bars (real data + gap fills) plus one `role: 'hitArea'` hit region per row,
11
+ * sized to that row's own segment.
22
12
  */
23
13
  export declare function buildBarMarkers<TData extends Record<string, unknown>>(args: {
24
14
  groupedSeries: VisualGroup<Series<TData>, BarSegmentStyle>[];
@@ -3,33 +3,23 @@ import { getConfiguration } from '../../configuration/config.svelte';
3
3
  import { isGapValue, resolveGapAreaStyle } from '../utils/gaps';
4
4
  const GAP_KINDS = ['start', 'middle', 'end'];
5
5
  /**
6
- * Every `'bar'` marker for one BarPlot render: one `role: 'segment'` marker
7
- * per series (stacked) or per visual segment (grouped/overlap), one
8
- * additional `role: 'segment'` marker per series per gap zone kind for any
9
- * row `styles.gaps` fills (distinctly styled, drawn at its interpolated
10
- * value — see `resolveDiscreteGapFills`), plus one `role: 'hitArea'` marker
11
- * per category, so a chart never gets segment markers without their hit
12
- * areas. A row whose value is missing and *not* filled by a gap zone is
13
- * skipped entirely — no bar drawn, rather than the broken zero/`NaN`
14
- * geometry a raw missing value would otherwise produce.
15
- *
16
- * `'hitArea'` geometry is left unresolved (just `category` + `data`) —
17
- * resolving it to pixels needs svelteplot's live scale, only available
18
- * 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.
19
9
  */
20
10
  export function buildBarMarkers(args) {
21
11
  const { groupedSeries, barLayout, isHorizontal, hoverPoints, colorFor, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
22
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));
23
15
  const segmentMarkers = [];
16
+ const hitAreaMarkers = [];
24
17
  groupedSeries.forEach((group, seriesIndex) => {
25
18
  const xFn = resolveAccessor(group.series.x);
26
19
  const yFn = resolveAccessor(group.series.y);
27
20
  const defaultColor = colorFor(group.series.name);
28
21
  const disabledFill = disabledFillFor(group.series.name);
29
- // Places one series' bar(s) real or gap-fill — using whichever
30
- // layout mode (stacked baseline, or flat baseline + grouped insets) is
31
- // active, so a gap-fill bar always lands in exactly the same slot a
32
- // real bar for that row would.
22
+ // Builds one series' visible bar geometry (real or gap-fill data).
33
23
  function makeMarker(data, valueFn, style) {
34
24
  if (data.length === 0)
35
25
  return null;
@@ -60,31 +50,81 @@ export function buildBarMarkers(args) {
60
50
  marker.y2 = valueFn;
61
51
  }
62
52
  if (barLayout.layout === 'grouped') {
53
+ const { start, end } = barLayout.groupInsetFraction(seriesIndex);
63
54
  if (isHorizontal) {
64
- marker.insetTop = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
65
- marker.insetBottom = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
55
+ marker.insetTop = start;
56
+ marker.insetBottom = end;
66
57
  }
67
58
  else {
68
- marker.insetLeft = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
69
- 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;
70
105
  }
71
106
  }
72
107
  }
73
108
  return marker;
74
109
  }
75
- // Mirrors the pre-existing behavior for both branches: a series-scoped
76
- // `segments` catch-all style (e.g. an explicit `fill`) wins over the
77
- // index-derived `defaultColor`.
78
110
  for (const seg of group.visualSegments) {
79
111
  const realData = seg.data.filter((d) => !isGapValue(yFn(d)));
80
112
  const style = {
81
113
  fill: disabledFill?.fill ?? seg.style.fill ?? defaultColor,
82
- fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1,
114
+ fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1
83
115
  };
84
116
  const valueFn = barLayout.layout === 'stacked' ? (d) => Number(yFn(d)) : yFn;
85
117
  const marker = makeMarker(realData, valueFn, style);
86
118
  if (marker)
87
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
+ }
88
128
  }
89
129
  const fillMap = gapFillMaps.get(group.series.name) ?? new Map();
90
130
  for (const kind of GAP_KINDS) {
@@ -95,36 +135,17 @@ export function buildBarMarkers(args) {
95
135
  const gapStyle = {
96
136
  ...base,
97
137
  fill: disabledFill?.fill ?? base.fill,
98
- fillOpacity: disabledFill?.fillOpacity ?? base.fillOpacity,
138
+ fillOpacity: disabledFill?.fillOpacity ?? base.fillOpacity
99
139
  };
100
- // svelteplot shallow-clones each row before invoking a mark's own
101
- // geometry accessors (same reason PyramidPlot's `flat` tags rows
102
- // instead of relying on identity) — a `fillMap.get(d)` lookup inside
103
- // `valueFn` would see a clone, not the original key. Tagging the
104
- // resolved value onto the row as a plain field survives that clone.
105
- 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
+ }));
106
145
  const marker = makeMarker(taggedData, (d) => d.__gapValue, gapStyle);
107
146
  if (marker)
108
147
  segmentMarkers.push(marker);
109
148
  }
110
149
  });
111
- const byCategory = new Map();
112
- for (const p of hoverPoints) {
113
- const key = isHorizontal ? p.y : p.x;
114
- const list = byCategory.get(key);
115
- if (list)
116
- list.push(p);
117
- else
118
- byCategory.set(key, [p]);
119
- }
120
- const hitAreaMarkers = [...byCategory.entries()].map(([category, points]) => ({
121
- type: 'bar',
122
- role: 'hitArea',
123
- isHorizontal,
124
- category,
125
- data: points.map((p) => p.row),
126
- onpointerenter: active ? () => setHoverMatch(points.map((p) => p.row)) : undefined,
127
- onpointerleave: active ? clearHoverMatch : undefined,
128
- }));
129
150
  return [...segmentMarkers, ...hitAreaMarkers];
130
151
  }
@@ -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
  }
@@ -144,6 +144,12 @@ export type ChartConfig = {
144
144
  bar: {
145
145
  /** Default gap-fill styling (`BarPlot`'s `styles.gaps`) — the fallback merged under whatever a gap zone's own `style` sets. */
146
146
  gap: AreaStyle;
147
+ /** Fallback `scales.z.seriesPadding` (0–1) for 'grouped' series layout, when a plot doesn't set its own. */
148
+ seriesPadding: number;
149
+ /** Fallback gap (0–1) *between* category groups on BarPlot's own category axis, when a plot doesn't set its own `scales.x/y.paddingInner`. */
150
+ paddingInner: number;
151
+ /** Fallback gap (0–1) before the first and after the last category group on BarPlot's own category axis, when a plot doesn't set its own `scales.x/y.paddingOuter`. */
152
+ paddingOuter: number;
147
153
  };
148
154
  /** Default pyramid-bar styling. */
149
155
  pyramid: {
@@ -5,8 +5,9 @@ import type { AxisValue } from './scales';
5
5
  * - `'x'` all points sharing the hovered x (default for line / bar).
6
6
  * - `'y'` all points sharing the hovered y (default for pyramid).
7
7
  * - `'punctual'` exact XY match — one cell at a time (default for heatmap).
8
+ * - `'series'` all points sharing the hovered series, regardless of x/y.
8
9
  */
9
- export type HoverStrategy = 'x' | 'y' | 'punctual';
10
+ export type HoverStrategy = 'x' | 'y' | 'punctual' | 'series';
10
11
  /**
11
12
  * Preferred horizontal placement of the floating tooltip relative to the
12
13
  * cursor: `'right'`/`'left'` offset it clear of the cursor (so it never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.50",
3
+ "version": "0.0.51",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"