@pond-ts/charts 0.44.0 → 0.44.1

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/CHANGELOG.md CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...HEAD
12
+ [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
12
13
  [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
13
14
  [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
14
15
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
@@ -42,6 +43,28 @@ and type-level changes; patch bumps are strictly additive.
42
43
 
43
44
  ## [Unreleased]
44
45
 
46
+ ## [0.44.1] — 2026-07-13
47
+
48
+ A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
49
+ `@pond-ts/react`, `@pond-ts/fit`, and `@pond-ts/financial` carry no code
50
+ changes — republished in lock-step; their `^0.44.0` peer ranges already
51
+ admit this patch.
52
+
53
+ ### Fixed
54
+
55
+ - **charts:** trading-time axis tick density now derives from plot width
56
+ instead of a fixed count of 5. The trading scale's tick `count` caps how many
57
+ calendar buckets `coarsenCalendar` may keep, so the fixed count coarsened any
58
+ ≳6-month daily view to quarter or year grain — a 1-year daily chart on a
59
+ ~900px plot showed 2 ticks; it now lands on month grain (~12). Continuous
60
+ (non-trading) time and value axes keep the fixed default, unchanged. The
61
+ count is shared through the container frame (`xTickCount`), so axis labels,
62
+ x gridlines, session dividers, and the cursor-time formatter all derive from
63
+ the same instants — previously the label formatter anchored at the scale's
64
+ internal default (10) while ticks used 5, which is why sparse year-grain
65
+ ticks were labelled with dates (`Jun 22`) instead of years. (Tidal friction
66
+ report, charts 0.44.)
67
+
45
68
  ## [0.44.0] — 2026-07-11
46
69
 
47
70
  The **value-axis charts** release: cross-sectional data (a volatility smile keyed
@@ -11,9 +11,18 @@ import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
11
11
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
12
12
  import { TimeAxis } from './TimeAxis.js';
13
13
  import { defaultTheme } from './theme.js';
14
- /** Time-axis tick count matches `<TimeAxis>` so the cursor-time formatter is
15
- * calibrated as the time-axis labels are. */
14
+ /** Tick count for a **continuous** (non-trading) x axis the `ticks(count)`
15
+ * request `<TimeAxis>`, the x gridlines, and the cursor-time formatter share
16
+ * (as the frame's `xTickCount`). */
16
17
  const TIME_TICK_COUNT = 5;
18
+ /** Target px of plot width per tick on a **trading-time** axis. That scale's
19
+ * `ticks(count)` treats `count` as a **cap on calendar buckets** (see
20
+ * `coarsenCalendar` — it picks the finest grain that fits), so the count must
21
+ * scale with the room the labels actually have: a fixed 5 coarsens any
22
+ * ≳6-month daily view to year grain — 2 ticks on a 900px plot. ~65px fits a
23
+ * `%b %d` anchor label at the default font plus breathing room, so a ~900px
24
+ * year-long daily view lands on month grain. */
25
+ const TRADING_TICK_PX = 65;
17
26
  /**
18
27
  * Normalize the `range` prop — a `[begin, end]` tuple or a `TimeRange` — to a
19
28
  * plain `[number, number]`, or `undefined` when omitted (→ auto-fit). The
@@ -323,6 +332,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
323
332
  ? calendar.discontinuities(spacing ? { spacing } : undefined)
324
333
  : undefined, [resolvedKind, discontinuities, calendar, spacing]);
325
334
  const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
335
+ // The shared x-side tick count — labels, x gridlines, session dividers, and
336
+ // `formatTime` all pass this one value, so they derive from the same instants
337
+ // (the alignment previously held by three hardcoded constants agreeing).
338
+ // Trading axis: width-derived, since the trading scale's `count` caps its
339
+ // calendar buckets rather than targeting a tick total; floored at 2 so a
340
+ // pre-layout zero width still requests a drawable tick set.
341
+ const xTickCount = xDiscontinuities !== undefined
342
+ ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
343
+ : TIME_TICK_COUNT;
326
344
  const { xScale, formatTime } = useMemo(() => {
327
345
  if (resolvedKind === 'category') {
328
346
  // Ordinal column-domain axis: a band scale over the category slots. The
@@ -339,24 +357,26 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
339
357
  const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
340
358
  return {
341
359
  xScale: s,
342
- formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
360
+ formatTime: resolveAxisFormat(s, xTickCount, timeFormat),
343
361
  };
344
362
  }
345
363
  if (xDiscontinuities !== undefined) {
346
364
  // Trading-time axis: closed-market gaps collapse, time proportional within
347
365
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
366
+ // `xTickCount` reaches `tickFormat` too: the trading scale picks its anchor
367
+ // grain from the count, so labels sit on the exact instants the ticks do.
348
368
  const s = scaleTradingTime(xDiscontinuities)
349
369
  .domain([d0, d1])
350
370
  .range([0, plotWidth]);
351
371
  return {
352
372
  xScale: s,
353
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
373
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
354
374
  };
355
375
  }
356
376
  const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
357
377
  return {
358
378
  xScale: s,
359
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
379
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
360
380
  };
361
381
  }, [
362
382
  resolvedKind,
@@ -366,6 +386,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
366
386
  plotWidth,
367
387
  timeFormat,
368
388
  xDiscontinuities,
389
+ xTickCount,
369
390
  ]);
370
391
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
371
392
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
@@ -478,6 +499,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
478
499
  onHoverAnnotation,
479
500
  onEditAnnotation,
480
501
  formatTime,
502
+ xTickCount,
481
503
  registerTrackerSource,
482
504
  unregisterTrackerSource,
483
505
  registerSelectable,
@@ -531,6 +553,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
531
553
  onHoverAnnotation,
532
554
  onEditAnnotation,
533
555
  formatTime,
556
+ xTickCount,
534
557
  registerTrackerSource,
535
558
  unregisterTrackerSource,
536
559
  registerSelectable,
package/dist/Layers.js CHANGED
@@ -7,10 +7,12 @@ import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
8
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
- /** Gridline tick count. **Must match the axis label counts** (`XAxis`
11
- * `TICK_COUNT`, `ChartContainer` `TIME_TICK_COUNT`, `YAxis`) — the grid, the
12
- * session dividers, and the axis labels are all derived from `ticks(count)`, so
13
- * they only line up while the counts agree. Kept at 5 across all four. */
10
+ /** **Y**-gridline tick count. **Must match the y-axis label counts** (`YAxis`
11
+ * `TICK_COUNT`, `ChartRow` `AXIS_TICK_COUNT`) — horizontal gridlines and the y
12
+ * labels are both derived from `ticks(count)`, so they only line up while the
13
+ * counts agree; kept at 5 across all three. The **x** side instead reads the
14
+ * container's shared `xTickCount` (as `<XAxis>` and `formatTime` do), which is
15
+ * width-derived on a trading-time axis. */
14
16
  const GRID_TICKS = 5;
15
17
  /** Minimum px between session dividers — thins dense collapse points (e.g. a
16
18
  * daily chart where every candle is a new session) so the axis never crowds. */
@@ -56,8 +58,10 @@ export function Layers({ children }) {
56
58
  const background = container.theme.background;
57
59
  const { grid: gridColor, gridDash } = container.theme.axis;
58
60
  const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
59
- // x geometry is shared and lives on the container (uniform across rows).
60
- const { xScale, plotWidth } = container;
61
+ // x geometry is shared and lives on the container (uniform across rows), and
62
+ // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
63
+ // labels, which pass the same `xTickCount` to the same scale.
64
+ const { xScale, plotWidth, xTickCount } = container;
61
65
  const draw = useCallback((ctx, w, h) => {
62
66
  if (background !== undefined) {
63
67
  ctx.fillStyle = background;
@@ -71,7 +75,7 @@ export function Layers({ children }) {
71
75
  const explicitY = tickValues.get(defaultAxisId);
72
76
  // A category axis draws no vertical gridlines — a line through each bar
73
77
  // centre reads as noise; the bars are the structure.
74
- const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
78
+ const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(xTickCount);
75
79
  const xTicks = xTickVals.map((d) => xScale(+d));
76
80
  const yTicks = gridY
77
81
  ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
@@ -104,6 +108,7 @@ export function Layers({ children }) {
104
108
  layers,
105
109
  yScales,
106
110
  xScale,
111
+ xTickCount,
107
112
  defaultAxisId,
108
113
  tickValues,
109
114
  background,
package/dist/XAxis.js CHANGED
@@ -7,7 +7,6 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
7
7
  const TICK_STRIP = 22;
8
8
  /** Extra height reserved for an axis `label` line. */
9
9
  const LABEL_STRIP = 16;
10
- const TICK_COUNT = 5;
11
10
  /**
12
11
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
13
12
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -53,7 +52,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
53
52
  if (container === null) {
54
53
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
55
54
  }
56
- const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
55
+ // `xTickCount` is the container's shared x-side count the same value the x
56
+ // gridlines and `formatTime` use, so labels and grid stay on the same instants
57
+ // (width-derived on a trading-time axis).
58
+ const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
57
59
  // The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
58
60
  // cursor is live in-bounds, pin the hovered time to this axis (covering the
59
61
  // tick behind it), matching the on-axis y value pills the rows draw. Gated on
@@ -76,8 +78,8 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
76
78
  format === undefined || xKind === 'category'
77
79
  ? formatTime
78
80
  : xKind === 'time'
79
- ? resolveTimeFormat(xScale, TICK_COUNT, format)
80
- : resolveAxisFormat(xScale, TICK_COUNT, format);
81
+ ? resolveTimeFormat(xScale, xTickCount, format)
82
+ : resolveAxisFormat(xScale, xTickCount, format);
81
83
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
82
84
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
83
85
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -126,7 +128,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
126
128
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
127
129
  const rawTicks = customTicks
128
130
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
129
- : xScale.ticks(TICK_COUNT).map((d) => ({
131
+ : xScale.ticks(xTickCount).map((d) => ({
130
132
  x: xScale(d),
131
133
  label: fmt(+d),
132
134
  }));
package/dist/context.d.ts CHANGED
@@ -151,6 +151,17 @@ export interface ContainerFrame {
151
151
  /** Format an epoch-ms instant the same way the time axis labels its ticks —
152
152
  * shared by `<TimeAxis>` and the cursor-time readout. */
153
153
  readonly formatTime: (epochMs: number) => string;
154
+ /**
155
+ * The shared **x-side tick count** — the `count` every x-side `ticks()` /
156
+ * `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
157
+ * session dividers, {@link formatTime}), so labels, grid, and dividers all
158
+ * derive from the same instants. A fixed default on a continuous axis;
159
+ * **width-derived on a trading-time axis**, where the count caps how many
160
+ * calendar buckets `coarsenCalendar` may keep — a fixed small count would
161
+ * coarsen any long daily view to year grain (2 ticks) no matter how wide
162
+ * the plot is.
163
+ */
164
+ readonly xTickCount: number;
154
165
  /**
155
166
  * Register a draw layer as a tracker source so the container can fan in every
156
167
  * series' value at the cursor for `onTrackerChanged`. Keyed by the layer's
package/dist/format.d.ts CHANGED
@@ -42,8 +42,10 @@ interface TimeTickable {
42
42
  * - a **function** → used as-is (called with epoch ms);
43
43
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
44
44
  * every value), wrapped to take epoch ms;
45
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
46
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
45
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
46
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
47
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
48
+ * axis's count here is what keeps the labels on the same grain as the ticks.
47
49
  *
48
50
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
49
51
  * formatter in `new Date(ms)`.
package/dist/format.js CHANGED
@@ -32,8 +32,10 @@ export function resolveAxisFormat(scale, count, format) {
32
32
  * - a **function** → used as-is (called with epoch ms);
33
33
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
34
34
  * every value), wrapped to take epoch ms;
35
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
36
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
35
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
36
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
37
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
38
+ * axis's count here is what keeps the labels on the same grain as the ticks.
37
39
  *
38
40
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
39
41
  * formatter in `new Date(ms)`.
@@ -41,7 +43,9 @@ export function resolveAxisFormat(scale, count, format) {
41
43
  export function resolveTimeFormat(scale, count, format) {
42
44
  if (typeof format === 'function')
43
45
  return format;
44
- const tf = format !== undefined ? scale.tickFormat(count, format) : scale.tickFormat();
46
+ const tf = format !== undefined
47
+ ? scale.tickFormat(count, format)
48
+ : scale.tickFormat(count);
45
49
  return (ms) => tf(new Date(ms));
46
50
  }
47
51
  //# sourceMappingURL=format.js.map
@@ -83,6 +83,12 @@ type TickGranularity = 'session' | 'week' | 'month' | 'quarter' | 'year';
83
83
  * `count` buckets and returns the first open in each; beyond yearly it decimates
84
84
  * every-nth so the axis never crowds. Exported so the container can draw session
85
85
  * dividers at the same instants the axis labels.
86
+ *
87
+ * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
88
+ * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
89
+ * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
90
+ * ticks). Callers size the cap to the room the labels have — the container
91
+ * derives it from plot width — rather than passing a small constant.
86
92
  */
87
93
  export declare function coarsenCalendar(opens: readonly number[], count: number): {
88
94
  ticks: number[];
@@ -52,6 +52,12 @@ const COARSENING_LADDER = [
52
52
  * `count` buckets and returns the first open in each; beyond yearly it decimates
53
53
  * every-nth so the axis never crowds. Exported so the container can draw session
54
54
  * dividers at the same instants the axis labels.
55
+ *
56
+ * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
57
+ * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
58
+ * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
59
+ * ticks). Callers size the cap to the room the labels have — the container
60
+ * derives it from plot width — rather than passing a small constant.
55
61
  */
56
62
  export function coarsenCalendar(opens, count) {
57
63
  if (opens.length <= count)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",