@pond-ts/charts 0.55.0 → 0.57.0

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/viewport.js CHANGED
@@ -36,28 +36,64 @@ export function clampToBounds(range, bounds) {
36
36
  return [hi - span, hi];
37
37
  return [range[0], range[1]];
38
38
  }
39
+ /**
40
+ * Snap a computed view range to **whole milliseconds** — the last step of every
41
+ * gesture that derives a range from pixels.
42
+ *
43
+ * A wheel-zoom or drag-pan turns a pixel position into a time via
44
+ * `xScale.invert()`, so the result is fractional *by construction*: an ordinary
45
+ * scroll produces `1.7e12 + 0.37`. The epoch millisecond is this model's atomic
46
+ * unit — a sub-millisecond view range is not a finer view, it is a number with
47
+ * no meaning — and downstream consumers are entitled to assume it. One of them
48
+ * did: `Temporal.Instant` refuses a non-integer epoch ms outright, so a
49
+ * `cursorSequence` over a calendar grain threw on a plain scroll and unmounted
50
+ * the page. Core now floors the instant, which fixes that symptom; rounding
51
+ * here closes the class, because nothing downstream ever sees the fraction.
52
+ *
53
+ * **Never collapses a positive span.** `[10.4, 10.6]` would otherwise round to
54
+ * `[10, 10]` — a zero-width view, which is a division by zero in every scale
55
+ * built from it. A span that survives rounding keeps its rounded width; one
56
+ * that doesn't is opened to the 1 ms floor. A range that arrives degenerate
57
+ * (`hi <= lo`) is passed through rounded, since widening it would invent a view
58
+ * the caller didn't ask for.
59
+ */
60
+ function roundRange(lo, hi) {
61
+ const a = Math.round(lo);
62
+ const b = Math.round(hi);
63
+ // `Math.round` is monotonic, so `b < a` is impossible for `hi >= lo`; the only
64
+ // way a positive span collapses is both ends landing on the same integer.
65
+ return b === a && hi > lo ? [a, a + 1] : [a, b];
66
+ }
39
67
  /**
40
68
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
41
- * dragging the plot right reveals earlier data, i.e. a negative `dt`.
69
+ * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
70
+ * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
71
+ * delta through `xScale.invert()`, so it is fractional by construction.
42
72
  */
43
73
  export function panRange(range, dt) {
44
- return [range[0] + dt, range[1] + dt];
74
+ return roundRange(range[0] + dt, range[1] + dt);
45
75
  }
46
76
  /**
47
77
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
48
78
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
49
79
  * duration never drops below `minDuration` (the zoom-in floor); at the floor the
50
80
  * pivot keeps its fractional position in the window.
81
+ *
82
+ * The result is snapped to whole milliseconds ({@link roundRange}). `minDuration`
83
+ * is applied **before** the snap, so the floor is honoured in the units the
84
+ * caller expressed it in; a `minDuration` below 1 ms cannot be represented and
85
+ * lands on the 1 ms floor the snap guarantees, which is the finest view this
86
+ * model has.
51
87
  */
52
88
  export function zoomRange(range, pivot, factor, minDuration = 1) {
53
89
  const lo = pivot - (pivot - range[0]) * factor;
54
90
  const hi = pivot + (range[1] - pivot) * factor;
55
91
  if (hi - lo >= minDuration)
56
- return [lo, hi];
92
+ return roundRange(lo, hi);
57
93
  // Floor reached: hold the pivot's fractional position, set span = minDuration.
58
94
  const span = range[1] - range[0];
59
95
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
60
- return [pivot - minDuration * frac, pivot + minDuration * (1 - frac)];
96
+ return roundRange(pivot - minDuration * frac, pivot + minDuration * (1 - frac));
61
97
  }
62
98
  /**
63
99
  * Pan a range on a **trading-time** axis: shift both endpoints by the same
package/dist/yticks.d.ts CHANGED
@@ -17,4 +17,48 @@
17
17
  * nice 1-2-5 values near it, so a larger count on a tall row is exactly right.
18
18
  */
19
19
  export declare function resolveYTickCount(height: number, explicit?: number | undefined): number;
20
+ /** The slice of a scale {@link yTickValues} reads. */
21
+ interface TickableScale {
22
+ ticks(count?: number): number[];
23
+ domain(): number[];
24
+ /** Present on d3's `scaleLog` and on no other continuous scale. */
25
+ base?: () => number;
26
+ }
27
+ /**
28
+ * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
29
+ * the one list, so a label and its gridline stay on the same instants.
30
+ *
31
+ * On a linear scale this is just `scale.ticks(count)`, whose 1-2-5 selection
32
+ * treats the count as a target. **On a log scale it cannot be**, because d3's
33
+ * `scaleLog.ticks(count)` is not a target at all — it is nearly a step
34
+ * function, and the jump is catastrophic. Measured against a real seven-decade
35
+ * domain (ESnet's traffic history, 1.9e10 → 2.6e17 bytes):
36
+ *
37
+ * | `count` | ticks returned |
38
+ * | ------- | -------------- |
39
+ * | 4 | 3 (every *other* decade — 1e12, 1e14, 1e16) |
40
+ * | 6 | 7 (every decade — the one right answer) |
41
+ * | 8 | **64** (every 2,3,…9 × decade) |
42
+ *
43
+ * Since the count is height-derived (`height / 48`), that means a 260px row
44
+ * silently labels 3 of 7 decades and a 400px row draws 64 gridlines and 64
45
+ * labels — a 40px resize flipping between them. Neither is a rendering nicety;
46
+ * both are unreadable.
47
+ *
48
+ * So for a log scale we pick the decades ourselves: every `k`th power of ten,
49
+ * with `k` the smallest step whose tick count fits the budget. That is what a
50
+ * log plot is conventionally gridded on, it degrades predictably as the row
51
+ * shrinks, and it never explodes.
52
+ *
53
+ * Below two decades of span there aren't enough powers of ten to grid with, and
54
+ * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
55
+ * behaved — so that case defers to the scale.
56
+ *
57
+ * Log detection is structural: `base()` exists on `scaleLog` and on no other
58
+ * continuous scale. The alternative is threading the axis kind down to the
59
+ * gridline site, which has no `AxisSpec` in scope — the same localized-shape
60
+ * approach `resolveBarBaseline` takes to read `.domain()`.
61
+ */
62
+ export declare function yTickValues(scale: TickableScale, count: number): number[];
63
+ export {};
20
64
  //# sourceMappingURL=yticks.d.ts.map
package/dist/yticks.js CHANGED
@@ -25,4 +25,59 @@ export function resolveYTickCount(height, explicit) {
25
25
  return Math.max(1, Math.floor(explicit));
26
26
  return Math.max(2, Math.floor(height / Y_TICK_PX));
27
27
  }
28
+ /**
29
+ * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
30
+ * the one list, so a label and its gridline stay on the same instants.
31
+ *
32
+ * On a linear scale this is just `scale.ticks(count)`, whose 1-2-5 selection
33
+ * treats the count as a target. **On a log scale it cannot be**, because d3's
34
+ * `scaleLog.ticks(count)` is not a target at all — it is nearly a step
35
+ * function, and the jump is catastrophic. Measured against a real seven-decade
36
+ * domain (ESnet's traffic history, 1.9e10 → 2.6e17 bytes):
37
+ *
38
+ * | `count` | ticks returned |
39
+ * | ------- | -------------- |
40
+ * | 4 | 3 (every *other* decade — 1e12, 1e14, 1e16) |
41
+ * | 6 | 7 (every decade — the one right answer) |
42
+ * | 8 | **64** (every 2,3,…9 × decade) |
43
+ *
44
+ * Since the count is height-derived (`height / 48`), that means a 260px row
45
+ * silently labels 3 of 7 decades and a 400px row draws 64 gridlines and 64
46
+ * labels — a 40px resize flipping between them. Neither is a rendering nicety;
47
+ * both are unreadable.
48
+ *
49
+ * So for a log scale we pick the decades ourselves: every `k`th power of ten,
50
+ * with `k` the smallest step whose tick count fits the budget. That is what a
51
+ * log plot is conventionally gridded on, it degrades predictably as the row
52
+ * shrinks, and it never explodes.
53
+ *
54
+ * Below two decades of span there aren't enough powers of ten to grid with, and
55
+ * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
56
+ * behaved — so that case defers to the scale.
57
+ *
58
+ * Log detection is structural: `base()` exists on `scaleLog` and on no other
59
+ * continuous scale. The alternative is threading the axis kind down to the
60
+ * gridline site, which has no `AxisSpec` in scope — the same localized-shape
61
+ * approach `resolveBarBaseline` takes to read `.domain()`.
62
+ */
63
+ export function yTickValues(scale, count) {
64
+ if (typeof scale.base !== 'function')
65
+ return scale.ticks(count);
66
+ const domain = scale.domain();
67
+ const lo = Math.min(domain[0], domain[domain.length - 1]);
68
+ const hi = Math.max(domain[0], domain[domain.length - 1]);
69
+ if (!(lo > 0) || !(hi > lo))
70
+ return scale.ticks(count);
71
+ const first = Math.ceil(Math.log10(lo));
72
+ const last = Math.floor(Math.log10(hi));
73
+ const decades = last - first + 1;
74
+ if (decades < 2)
75
+ return scale.ticks(count);
76
+ const budget = Math.max(2, count);
77
+ const step = Math.max(1, Math.ceil(decades / budget));
78
+ const out = [];
79
+ for (let e = first; e <= last; e += step)
80
+ out.push(10 ** e);
81
+ return out;
82
+ }
28
83
  //# sourceMappingURL=yticks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.55.0",
42
- "pond-ts": "^0.55.0",
41
+ "@pond-ts/react": "^0.57.0",
42
+ "pond-ts": "^0.57.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {