@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(categoricalAxis === 'x' ? withDefaultPadding(scales.x) : scales.x),
181
- y: stripCategoricalType(categoricalAxis === 'y' ? withDefaultPadding(scales.y) : scales.y)
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"