@pond-ts/charts 0.58.0 → 0.60.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
@@ -70,8 +70,26 @@ function roundRange(lo, hi) {
70
70
  * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
71
71
  * delta through `xScale.invert()`, so it is fractional by construction.
72
72
  */
73
- export function panRange(range, dt) {
74
- return roundRange(range[0] + dt, range[1] + dt);
73
+ export function panRange(range, dt, options = {}) {
74
+ const { log = false, snap = !log } = options;
75
+ if (log) {
76
+ // On a log axis a pixel drag is a RATIO, not an offset. `dt` arrives as a
77
+ // domain delta from `xScale.invert`, which on a log scale is meaningless as
78
+ // an addend: adding 100s near the 1s end walks off the plot, and near the
79
+ // 3h end barely moves. Convert it to the fraction of the visible decades it
80
+ // represents and shift by that instead, so a drag of N pixels moves the
81
+ // same visual distance wherever it starts.
82
+ const [lo, hi] = range;
83
+ if (!(lo > 0) || !(hi > lo))
84
+ return [lo, hi];
85
+ const span = hi - lo;
86
+ const f = span > 0 ? dt / span : 0; // fraction of the window dragged
87
+ const k = Math.exp(Math.log(hi / lo) * f); // …as a ratio over the decades
88
+ return [lo * k, hi * k];
89
+ }
90
+ const lo = range[0] + dt;
91
+ const hi = range[1] + dt;
92
+ return snap ? roundRange(lo, hi) : [lo, hi];
75
93
  }
76
94
  /**
77
95
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
@@ -85,15 +103,44 @@ export function panRange(range, dt) {
85
103
  * lands on the 1 ms floor the snap guarantees, which is the finest view this
86
104
  * model has.
87
105
  */
88
- export function zoomRange(range, pivot, factor, minDuration = 1) {
106
+ export function zoomRange(range, pivot, factor, minDuration = 1, options = {}) {
107
+ const { log = false, snap = !log } = options;
108
+ if (log) {
109
+ // The same arithmetic, done in log space — which is where a log axis is
110
+ // linear. Zooming a log domain multiplicatively is what keeps the pivot
111
+ // under the cursor; doing it additively (as the linear branch does) drags
112
+ // the value under the pointer sideways, which is the one thing zoom must
113
+ // never do.
114
+ //
115
+ // `minDuration` is read as a minimum RATIO between the ends rather than a
116
+ // minimum difference, because a span on a log axis is a number of decades.
117
+ const [lo0, hi0] = range;
118
+ if (!(lo0 > 0) || !(hi0 > lo0) || !(pivot > 0))
119
+ return [lo0, hi0];
120
+ const L = Math.log(lo0);
121
+ const H = Math.log(hi0);
122
+ const P = Math.min(H, Math.max(L, Math.log(pivot)));
123
+ let l = P - (P - L) * factor;
124
+ let h = P + (H - P) * factor;
125
+ const floor = Math.log(Math.max(minDuration, 1 + 1e-9)); // ratio → decades
126
+ if (h - l < floor) {
127
+ const frac = H - L > 0 ? (P - L) / (H - L) : 0.5;
128
+ l = P - floor * frac;
129
+ h = P + floor * (1 - frac);
130
+ }
131
+ return [Math.exp(l), Math.exp(h)];
132
+ }
89
133
  const lo = pivot - (pivot - range[0]) * factor;
90
134
  const hi = pivot + (range[1] - pivot) * factor;
91
- if (hi - lo >= minDuration)
92
- return roundRange(lo, hi);
135
+ if (hi - lo >= minDuration) {
136
+ return snap ? roundRange(lo, hi) : [lo, hi];
137
+ }
93
138
  // Floor reached: hold the pivot's fractional position, set span = minDuration.
94
139
  const span = range[1] - range[0];
95
140
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
96
- return roundRange(pivot - minDuration * frac, pivot + minDuration * (1 - frac));
141
+ const flo = pivot - minDuration * frac;
142
+ const fhi = pivot + minDuration * (1 - frac);
143
+ return snap ? roundRange(flo, fhi) : [flo, fhi];
97
144
  }
98
145
  /**
99
146
  * Pan a range on a **trading-time** axis: shift both endpoints by the same
package/dist/yticks.d.ts CHANGED
@@ -23,6 +23,9 @@ interface TickableScale {
23
23
  domain(): number[];
24
24
  /** Present on d3's `scaleLog` and on no other continuous scale. */
25
25
  base?: () => number;
26
+ /** Present on d3's `scaleSymlog` and on no other continuous scale — the
27
+ * linear window's half-width, i.e. the knee ([PND-SYMLOG]). */
28
+ constant?: () => number;
26
29
  }
27
30
  /**
28
31
  * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
@@ -54,11 +57,15 @@ interface TickableScale {
54
57
  * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
55
58
  * behaved — so that case defers to the scale.
56
59
  *
60
+ * Used by **both** axes. Nothing in here was ever y-specific — it detects log
61
+ * and symlog structurally and defers to the scale for everything else — and
62
+ * `<ChartContainer xScale="log">` made that concrete ([PND-XLOG]).
63
+ *
57
64
  * Log detection is structural: `base()` exists on `scaleLog` and on no other
58
65
  * continuous scale. The alternative is threading the axis kind down to the
59
66
  * gridline site, which has no `AxisSpec` in scope — the same localized-shape
60
67
  * approach `resolveBarBaseline` takes to read `.domain()`.
61
68
  */
62
- export declare function yTickValues(scale: TickableScale, count: number): number[];
69
+ export declare function tickValues(scale: TickableScale, count: number): number[];
63
70
  export {};
64
71
  //# sourceMappingURL=yticks.d.ts.map
package/dist/yticks.js CHANGED
@@ -55,12 +55,18 @@ export function resolveYTickCount(height, explicit) {
55
55
  * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
56
56
  * behaved — so that case defers to the scale.
57
57
  *
58
+ * Used by **both** axes. Nothing in here was ever y-specific — it detects log
59
+ * and symlog structurally and defers to the scale for everything else — and
60
+ * `<ChartContainer xScale="log">` made that concrete ([PND-XLOG]).
61
+ *
58
62
  * Log detection is structural: `base()` exists on `scaleLog` and on no other
59
63
  * continuous scale. The alternative is threading the axis kind down to the
60
64
  * gridline site, which has no `AxisSpec` in scope — the same localized-shape
61
65
  * approach `resolveBarBaseline` takes to read `.domain()`.
62
66
  */
63
- export function yTickValues(scale, count) {
67
+ export function tickValues(scale, count) {
68
+ if (typeof scale.constant === 'function')
69
+ return symlogTickValues(scale, count);
64
70
  if (typeof scale.base !== 'function')
65
71
  return scale.ticks(count);
66
72
  const domain = scale.domain();
@@ -80,4 +86,106 @@ export function yTickValues(scale, count) {
80
86
  out.push(10 ** e);
81
87
  return out;
82
88
  }
89
+ /**
90
+ * Tick values for a **symlog** axis — linear through zero, logarithmic beyond
91
+ * ([PND-SYMLOG]).
92
+ *
93
+ * **This is the feature, not a refinement of it.** d3's `scaleSymlog` supplies
94
+ * the transform but its `ticks()` is `linearish` — evenly spaced in *value*. On
95
+ * a ±1M domain with a 20k knee that yields `-1M, -500k, 0, 500k, 1M`: **nothing
96
+ * at all below the knee**, which is the region a symlog axis exists to reveal.
97
+ * The mapping does spread that region generously (0→250px, 20k→294px,
98
+ * 100k→364px on a 500px range), so such a chart is readable-but-unlabelled —
99
+ * confidently gridded on the one part of the scale that isn't the point. Owning
100
+ * the ladder is therefore inseparable from owning the transform.
101
+ *
102
+ * The ladder, and why each piece is there:
103
+ *
104
+ * - **Zero, always.** It is the axis's centre of symmetry and the one value a
105
+ * symlog scale is chosen to keep visible.
106
+ * - **The knee, ±`constant`.** Where the reading changes from linear to
107
+ * logarithmic. Unlabelled, a reader has no way to know which régime a given
108
+ * gap belongs to, and the same pixel distance means different things either
109
+ * side of it.
110
+ * - **Decades beyond the knee, mirrored.** What a log plot is conventionally
111
+ * gridded on, thinned by the same "every `k`th power of ten" rule the log path
112
+ * above uses, so it degrades predictably as the row shrinks instead of
113
+ * exploding.
114
+ * - **Bounds are NOT labelled.** A data-derived bound is rarely round, so
115
+ * printing it puts an arbitrary number next to a decade — the noise a log grid
116
+ * exists to avoid.
117
+ *
118
+ * Below one decade of span past the knee there is nothing to grid
119
+ * logarithmically, so it defers to `scale.ticks(count)` — which is linear, and
120
+ * correct, because inside the knee symlog *is* linear.
121
+ *
122
+ * **Clip first, then thin — never the other way round.** A pan/zoom (or explicit
123
+ * bounds) can leave a window that contains *none* of the ideal ladder:
124
+ * `[510_000, 990_000]` with a 19_800 knee excludes zero, both knees, and its one
125
+ * candidate decade, so an order that thinned a symmetric ladder and clipped
126
+ * afterwards handed **`[]`** to the labels and the gridlines — an axis with no
127
+ * ticks at all, which reads as a rendering failure rather than as a scale. The
128
+ * budget is likewise spent on what *survives* the domain, not on an ideal
129
+ * two-sided ladder, so an asymmetric window is not thinned as if it were twice
130
+ * its size. If nothing survives, the linear ticks are the honest answer.
131
+ *
132
+ * Detection is structural, matching the log path's use of `base()`: `constant()`
133
+ * exists on `scaleSymlog` and on no other continuous scale.
134
+ */
135
+ function symlogTickValues(scale, count) {
136
+ const domain = scale.domain();
137
+ const lo = Math.min(domain[0], domain[domain.length - 1]);
138
+ const hi = Math.max(domain[0], domain[domain.length - 1]);
139
+ const knee = Math.abs(scale.constant?.() ?? 1);
140
+ const maxAbs = Math.max(Math.abs(lo), Math.abs(hi));
141
+ // Finiteness is checked, not assumed: an explicit `max={Infinity}` reaches here
142
+ // intact, and `floor(log10(Infinity))` is `Infinity` — which made the decade
143
+ // loop's `e += step` a no-op and hung the render in a `for` that could never
144
+ // end. d3's own linear ticks return `[]` on such a domain, so deferring is both
145
+ // safe and the truthful answer for a domain with no finite extent.
146
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || !Number.isFinite(knee))
147
+ return scale.ticks(count);
148
+ if (!(knee > 0) || !(maxAbs > knee) || !(hi > lo))
149
+ return scale.ticks(count);
150
+ // The ladder starts at the first decade **at least half a decade above the
151
+ // knee** (`× √10`), not merely above it. `ceil(log10(knee))` alone puts a
152
+ // decade arbitrarily close to the knee tick whenever the knee lands just under
153
+ // a power of ten — a data-derived `maxAbs` of 4.95e6 gives a 99k knee and a
154
+ // 100k decade, two ticks a few pixels apart whose labels round to the *same
155
+ // string*. Since the knee itself is always drawn, dropping that decade loses
156
+ // no information; a chart that prints one number twice at two positions is
157
+ // reporting something false about the scale.
158
+ const firstExp = Math.ceil(Math.log10(knee) + 0.5);
159
+ const lastExp = Math.floor(Math.log10(maxAbs));
160
+ const inDomain = (v) => v >= lo && v <= hi;
161
+ // Zero and ±knee are the fixed part of the ladder — kept whole, never thinned,
162
+ // because they are what distinguishes a symlog axis from a log one.
163
+ const fixed = [0, knee, -knee].filter(inDomain);
164
+ // Every decade past the knee that the domain actually contains, in ascending
165
+ // magnitude, each carrying whichever of ±10^e survived.
166
+ const rungs = [];
167
+ for (let e = firstExp; e <= lastExp; e += 1) {
168
+ const pair = [10 ** e, -(10 ** e)].filter(inDomain);
169
+ if (pair.length > 0)
170
+ rungs.push(pair);
171
+ }
172
+ // No rung survives ⇒ there is no logarithmic region to grid, so the linear
173
+ // ticks are the answer — the same reasoning as the knee-swallows-the-domain
174
+ // guard above, and the case that used to produce an empty axis. Note this also
175
+ // covers a window sitting *between* the knee and the first decade.
176
+ if (rungs.length === 0)
177
+ return scale.ticks(count);
178
+ // Thin the SURVIVING rungs to what is left of the budget after the fixed
179
+ // ticks. Stepping by magnitude keeps a rung's ± pair together, so the grid
180
+ // stays symmetric wherever the domain is.
181
+ const budget = Math.max(2, count);
182
+ const total = rungs.reduce((n, pair) => n + pair.length, 0);
183
+ const room = Math.max(1, budget - fixed.length);
184
+ const step = Math.max(1, Math.ceil(total / room));
185
+ const out = new Set(fixed);
186
+ for (let i = 0; i < rungs.length; i += step)
187
+ for (const v of rungs[i])
188
+ out.add(v);
189
+ return [...out].sort((a, b) => a - b);
190
+ }
83
191
  //# sourceMappingURL=yticks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -24,11 +24,12 @@
24
24
  },
25
25
  "files": [
26
26
  "dist",
27
- "CHANGELOG.md"
27
+ "CHANGELOG.md",
28
+ "API.md"
28
29
  ],
29
30
  "scripts": {
30
31
  "build": "tsc -p tsconfig.json",
31
- "prepack": "cp ../../LICENSE ./LICENSE && cp ../../CHANGELOG.md ./CHANGELOG.md && npm run build && cp cjs-fallback.cjs dist/cjs-fallback.cjs && find dist -name '*.map' -delete",
32
+ "prepack": "cp ../../LICENSE ./LICENSE && cp ../../CHANGELOG.md ./CHANGELOG.md && cp ../../API.md ./API.md && npm run build && cp cjs-fallback.cjs dist/cjs-fallback.cjs && find dist -name '*.map' -delete",
32
33
  "test": "npm run test:type && npm run test:runtime",
33
34
  "test:type": "tsc -p tsconfig.types.json",
34
35
  "test:runtime": "vitest run",
@@ -38,8 +39,8 @@
38
39
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
40
  },
40
41
  "peerDependencies": {
41
- "@pond-ts/react": "^0.58.0",
42
- "pond-ts": "^0.58.0",
42
+ "@pond-ts/react": "^0.60.0",
43
+ "pond-ts": "^0.60.0",
43
44
  "react": "^18.0.0 || ^19.0.0"
44
45
  },
45
46
  "devDependencies": {