@fundar/data-chart-telling 0.0.51 → 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.
|
@@ -4,3 +4,20 @@ export declare function tickStep(start: number, stop: number, count: number): nu
|
|
|
4
4
|
export declare function ticksInRange(start: number, stop: number, step: number): number[];
|
|
5
5
|
/** Ticks-per-axis heuristic — used by `ScalesLayout` to pick a target count from an axis's actual pixel span. */
|
|
6
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];
|
|
@@ -32,3 +32,28 @@ export function ticksInRange(start, stop, step) {
|
|
|
32
32
|
export function naturalTickCount(span, spacing, min) {
|
|
33
33
|
return Math.max(min, Math.round(span / spacing));
|
|
34
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
|
+
}
|
|
@@ -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';
|
|
@@ -170,6 +174,78 @@
|
|
|
170
174
|
paddingOuter: scale?.paddingOuter ?? scale?.padding ?? cfg.bar.paddingOuter
|
|
171
175
|
};
|
|
172
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
|
+
|
|
173
249
|
// The single, fully-resolved scales object both BasePlotLayout (ticks,
|
|
174
250
|
// grid, sort, the real svelteplot scale) and `createBarLayout` (the
|
|
175
251
|
// grouped-bar-width estimate) read for everything — avoids keeping a
|
|
@@ -177,8 +253,12 @@
|
|
|
177
253
|
// the one actually forwarded.
|
|
178
254
|
const resolvedScales = $derived({
|
|
179
255
|
...scales,
|
|
180
|
-
x: stripCategoricalType(
|
|
181
|
-
|
|
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
|
+
)
|
|
182
262
|
});
|
|
183
263
|
|
|
184
264
|
// The accessor feeding each *visual* channel — swapped from the
|