@fundar/data-chart-telling 0.0.50 → 0.0.52
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.
- package/dist/configuration/config.svelte.js +6 -1
- package/dist/configuration/themes/index.d.ts +12 -0
- package/dist/layout/scales/ScalesLayout.svelte +87 -10
- package/dist/layout/scales/buildScale.d.ts +18 -1
- package/dist/layout/scales/buildScale.js +49 -2
- package/dist/layout/scales/niceDomain.d.ts +23 -0
- package/dist/layout/scales/niceDomain.js +59 -0
- package/dist/layout/tooltip/controller.svelte.js +23 -11
- package/dist/layout/tooltip/utils.js +3 -0
- package/dist/markers/BarMarker.svelte +145 -123
- package/dist/plots/bar/Plot.svelte +103 -10
- package/dist/plots/bar/buildBarMarkers.d.ts +3 -13
- package/dist/plots/bar/buildBarMarkers.js +71 -50
- package/dist/plots/bar/layout.svelte.d.ts +4 -0
- package/dist/plots/bar/layout.svelte.js +62 -17
- package/dist/types/configuration/styling.d.ts +6 -0
- package/dist/types/layout/tooltip.d.ts +2 -1
- package/package.json +1 -1
|
@@ -115,7 +115,12 @@ export const DEFAULT_CONFIG = {
|
|
|
115
115
|
connectorWidth: 0.5
|
|
116
116
|
}
|
|
117
117
|
},
|
|
118
|
-
bar: {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
|
|
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,23 @@
|
|
|
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;
|
|
7
|
+
/**
|
|
8
|
+
* Widens `[lo, hi]` outward to the nearest `count`-ish "nice" step —
|
|
9
|
+
* equivalent to d3's own `scale.nice(count)`, except a value within
|
|
10
|
+
* `epsilonFraction × step` of a step boundary is treated as already sitting
|
|
11
|
+
* on it, rather than needing a full extra step to clear it.
|
|
12
|
+
*
|
|
13
|
+
* Exists because plain `Math.ceil`/`Math.floor` (what `nice()` uses
|
|
14
|
+
* internally, both here and in d3) has zero tolerance: a value meant to be
|
|
15
|
+
* exactly `100` that comes out `100.00000000000001` after summing several
|
|
16
|
+
* independently-rounded parts (a common shape for "these categories add up
|
|
17
|
+
* to a whole" data, e.g. percentages of a total) still fails `hi <= step*N`
|
|
18
|
+
* and gets rounded up a full step further than the data actually warrants
|
|
19
|
+
* (`100` → `110`, not just a hair past `100`). `epsilonFraction` defaults to
|
|
20
|
+
* 1% of the step — small enough to only catch rounding/summation noise, not
|
|
21
|
+
* data that's genuinely meaningfully past the boundary.
|
|
22
|
+
*/
|
|
23
|
+
export declare function niceDomain(lo: number, hi: number, count?: number, epsilonFraction?: number): [number, number];
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Widens `[lo, hi]` outward to the nearest `count`-ish "nice" step —
|
|
37
|
+
* equivalent to d3's own `scale.nice(count)`, except a value within
|
|
38
|
+
* `epsilonFraction × step` of a step boundary is treated as already sitting
|
|
39
|
+
* on it, rather than needing a full extra step to clear it.
|
|
40
|
+
*
|
|
41
|
+
* Exists because plain `Math.ceil`/`Math.floor` (what `nice()` uses
|
|
42
|
+
* internally, both here and in d3) has zero tolerance: a value meant to be
|
|
43
|
+
* exactly `100` that comes out `100.00000000000001` after summing several
|
|
44
|
+
* independently-rounded parts (a common shape for "these categories add up
|
|
45
|
+
* to a whole" data, e.g. percentages of a total) still fails `hi <= step*N`
|
|
46
|
+
* and gets rounded up a full step further than the data actually warrants
|
|
47
|
+
* (`100` → `110`, not just a hair past `100`). `epsilonFraction` defaults to
|
|
48
|
+
* 1% of the step — small enough to only catch rounding/summation noise, not
|
|
49
|
+
* data that's genuinely meaningfully past the boundary.
|
|
50
|
+
*/
|
|
51
|
+
export function niceDomain(lo, hi, count = 5, epsilonFraction = 0.01) {
|
|
52
|
+
if (!(hi > lo))
|
|
53
|
+
return [lo, hi];
|
|
54
|
+
const step = tickStep(lo, hi, count);
|
|
55
|
+
const eps = step * epsilonFraction;
|
|
56
|
+
const niceLo = Math.floor((lo + eps) / step) * step;
|
|
57
|
+
const niceHi = Math.ceil((hi - eps) / step) * step;
|
|
58
|
+
return [niceLo, niceHi];
|
|
59
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeries
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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))
|
|
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 }) => ({
|
|
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
|
-
|
|
86
|
-
h.
|
|
87
|
-
h.
|
|
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
|
-
|
|
3
|
-
|
|
2
|
+
import { BarX, BarY, usePlot } from 'svelteplot';
|
|
3
|
+
import type { BarMarkerConfig } from '../types/markers/common';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
omitDisabledSeries,
|
|
9
9
|
buildSeriesColorIndex
|
|
10
10
|
} from '../utils/legendDisabled';
|
|
11
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
11
12
|
import { resolveAccessor } from '../utils/accessors';
|
|
12
13
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
13
14
|
import {
|
|
@@ -15,6 +16,9 @@
|
|
|
15
16
|
validateSegments,
|
|
16
17
|
buildScopedMarkerGroups
|
|
17
18
|
} from '../utils/segments';
|
|
19
|
+
import { isGapValue } from '../utils/gaps';
|
|
20
|
+
import { niceDomain, naturalTickCount } from '../../layout/scales/niceDomain';
|
|
21
|
+
import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
18
22
|
import { createBarLayout } from './layout.svelte';
|
|
19
23
|
import {
|
|
20
24
|
createLabelMarginTracker,
|
|
@@ -33,7 +37,7 @@
|
|
|
33
37
|
} from '../../layout/tooltip/utils';
|
|
34
38
|
import type { AxisBasedMarkersConfig } from '../../types/markers/common';
|
|
35
39
|
import type { BasePlotProps } from '../../types/plots/props';
|
|
36
|
-
import type { AxisScale, AxisBasedScalesConfig } from '../../types/layout/scales';
|
|
40
|
+
import type { AxisValue, AxisScale, AxisBasedScalesConfig } from '../../types/layout/scales';
|
|
37
41
|
import type { Series, SeriesProps, DataProps } from '../../types/plots/data/common';
|
|
38
42
|
import type { GapsAwarePlotStylesConfig } from '../../types/layout/styles';
|
|
39
43
|
import type { ValueAnchor } from '../../types/plots/constants';
|
|
@@ -158,14 +162,103 @@
|
|
|
158
162
|
delete rest.type;
|
|
159
163
|
return rest;
|
|
160
164
|
}
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
+
// Backs the category axis's `paddingInner`/`paddingOuter` — the gap
|
|
166
|
+
// *between* category groups — with the theme's `bar.paddingInner`/
|
|
167
|
+
// `paddingOuter` default, when the plot doesn't set its own (or the
|
|
168
|
+
// `padding` shorthand). BarPlot-only, unlike svelteplot's own hardcoded
|
|
169
|
+
// 0.15 fallback for every band scale.
|
|
170
|
+
function withDefaultPadding(scale?: AxisScale<TData>): AxisScale<TData> {
|
|
171
|
+
return {
|
|
172
|
+
...scale,
|
|
173
|
+
paddingInner: scale?.paddingInner ?? scale?.padding ?? cfg.bar.paddingInner,
|
|
174
|
+
paddingOuter: scale?.paddingOuter ?? scale?.padding ?? cfg.bar.paddingOuter
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Mirrors `createBarLayout`'s own `layout` derivation, read off the raw
|
|
179
|
+
// `scales` prop rather than `resolvedScales` — `valueExtent`/
|
|
180
|
+
// `withValueDomain` below feed `resolvedScales`, so reading it back off
|
|
181
|
+
// `resolvedScales` (or `barLayout.layout`, built from it) would be circular.
|
|
182
|
+
const seriesLayout = $derived(scales.z?.seriesLayout ?? 'overlap');
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The value axis's real rendered range — `[0, ...]` for 'grouped'/'overlap'
|
|
186
|
+
* (each row's own value, same range `svelteplot`'s own auto-domain would
|
|
187
|
+
* find), but for 'stacked' the *per-category total* (every series summed),
|
|
188
|
+
* which a plain per-row scan can't see. Needed so `withValueDomain` can
|
|
189
|
+
* nice-widen the axis itself instead of deferring to `buildScale`'s
|
|
190
|
+
* `nice: true` default, which only ever sees raw per-row values and would
|
|
191
|
+
* under-widen a stacked total's real top.
|
|
192
|
+
*/
|
|
193
|
+
const valueExtent = $derived.by((): [number, number] | undefined => {
|
|
194
|
+
let lo = 0;
|
|
195
|
+
let hi = 0;
|
|
196
|
+
if (seriesLayout === 'stacked') {
|
|
197
|
+
const totals = new SvelteMap<AxisValue, number>();
|
|
198
|
+
for (const s of resolvedSeries) {
|
|
199
|
+
const sx = resolveAccessor(s.x);
|
|
200
|
+
const sy = resolveAccessor(s.y);
|
|
201
|
+
for (const row of s.data) {
|
|
202
|
+
const raw = sy(row);
|
|
203
|
+
if (isGapValue(raw)) continue;
|
|
204
|
+
const cat = sx(row);
|
|
205
|
+
totals.set(cat, (totals.get(cat) ?? 0) + Number(raw));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const total of totals.values()) {
|
|
209
|
+
lo = Math.min(lo, total);
|
|
210
|
+
hi = Math.max(hi, total);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
for (const s of resolvedSeries) {
|
|
214
|
+
const sy = resolveAccessor(s.y);
|
|
215
|
+
for (const row of s.data) {
|
|
216
|
+
const raw = sy(row);
|
|
217
|
+
if (isGapValue(raw)) continue;
|
|
218
|
+
const v = Number(raw);
|
|
219
|
+
lo = Math.min(lo, v);
|
|
220
|
+
hi = Math.max(hi, v);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return hi > lo ? [lo, hi] : undefined;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Nice-widens the value axis using `valueExtent` (baseline/stack-aware,
|
|
229
|
+
* unlike `buildScale`'s own `nice: true` default) with an epsilon
|
|
230
|
+
* tolerance — see `niceDomain` — so a stacked total that's supposed to
|
|
231
|
+
* land exactly on a round number (a "parts of a whole" dataset, e.g.
|
|
232
|
+
* percentages summing to 100) doesn't get bumped a full step further by
|
|
233
|
+
* float noise from summing already-rounded parts. Skipped once the
|
|
234
|
+
* caller sets their own `domain`/`ticks`/`nice`.
|
|
235
|
+
*/
|
|
236
|
+
function withValueDomain(scale?: AxisScale<TData>): AxisScale<TData> | undefined {
|
|
237
|
+
if (scale?.domain != null || scale?.ticks != null || scale?.nice != null) return scale;
|
|
238
|
+
if (!valueExtent) return scale;
|
|
239
|
+
const [lo, hi] = valueExtent;
|
|
240
|
+
const spacing = isHorizontal ? 80 : 50;
|
|
241
|
+
const marginEstimate = isHorizontal
|
|
242
|
+
? AUTO_MARGIN_ESTIMATE.left + AUTO_MARGIN_ESTIMATE.right
|
|
243
|
+
: AUTO_MARGIN_ESTIMATE.top + AUTO_MARGIN_ESTIMATE.bottom;
|
|
244
|
+
const span = Math.max(0, (isHorizontal ? width : height) - marginEstimate);
|
|
245
|
+
const count = naturalTickCount(span, spacing, 2);
|
|
246
|
+
return { ...scale, domain: niceDomain(lo, hi, count) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The single, fully-resolved scales object both BasePlotLayout (ticks,
|
|
250
|
+
// grid, sort, the real svelteplot scale) and `createBarLayout` (the
|
|
251
|
+
// grouped-bar-width estimate) read for everything — avoids keeping a
|
|
252
|
+
// second, separately-patched copy of `scales.x`/`scales.y` in sync with
|
|
253
|
+
// the one actually forwarded.
|
|
165
254
|
const resolvedScales = $derived({
|
|
166
255
|
...scales,
|
|
167
|
-
x: stripCategoricalType(
|
|
168
|
-
|
|
256
|
+
x: stripCategoricalType(
|
|
257
|
+
categoricalAxis === 'x' ? withDefaultPadding(scales.x) : withValueDomain(scales.x)
|
|
258
|
+
),
|
|
259
|
+
y: stripCategoricalType(
|
|
260
|
+
categoricalAxis === 'y' ? withDefaultPadding(scales.y) : withValueDomain(scales.y)
|
|
261
|
+
)
|
|
169
262
|
});
|
|
170
263
|
|
|
171
264
|
// The accessor feeding each *visual* channel — swapped from the
|
|
@@ -179,7 +272,7 @@
|
|
|
179
272
|
* rendering, value labels, and hover markers below. See `layout.svelte.ts`.
|
|
180
273
|
*/
|
|
181
274
|
const barLayout = createBarLayout<TData>({
|
|
182
|
-
scales: () =>
|
|
275
|
+
scales: () => resolvedScales,
|
|
183
276
|
isHorizontal: () => isHorizontal,
|
|
184
277
|
width: () => width,
|
|
185
278
|
height: () => height,
|
|
@@ -233,8 +326,8 @@
|
|
|
233
326
|
// (via svelteplot's `RectPath`) actually render that band, which is
|
|
234
327
|
// enough to mismatch the neighbouring category once bands get thin
|
|
235
328
|
// (many categories). The `role: 'hitArea'` bar markers `buildBarMarkers`
|
|
236
|
-
// emits hit-test
|
|
237
|
-
// comment.
|
|
329
|
+
// emits hit-test each series' own segment directly instead — see its
|
|
330
|
+
// own doc comment.
|
|
238
331
|
pointMatching: 'contains' as const
|
|
239
332
|
});
|
|
240
333
|
|
|
@@ -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:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
//
|
|
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 =
|
|
65
|
-
marker.insetBottom =
|
|
55
|
+
marker.insetTop = start;
|
|
56
|
+
marker.insetBottom = end;
|
|
66
57
|
}
|
|
67
58
|
else {
|
|
68
|
-
marker.insetLeft =
|
|
69
|
-
marker.insetRight =
|
|
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
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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() -
|
|
70
|
-
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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(
|
|
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
|
|
118
|
-
*
|
|
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() {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
get
|
|
132
|
-
|
|
133
|
-
|
|
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
|