@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 +24 -1
- package/dist/ChartContainer.js +28 -5
- package/dist/Layers.js +12 -7
- package/dist/XAxis.js +7 -5
- package/dist/context.d.ts +11 -0
- package/dist/format.d.ts +4 -2
- package/dist/format.js +7 -3
- package/dist/tradingTimeScale.d.ts +6 -0
- package/dist/tradingTimeScale.js +6 -0
- package/package.json +1 -1
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.
|
|
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
|
package/dist/ChartContainer.js
CHANGED
|
@@ -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
|
-
/**
|
|
15
|
-
*
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
11
|
-
* `TICK_COUNT`, `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
80
|
-
: resolveAxisFormat(xScale,
|
|
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(
|
|
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()` —
|
|
46
|
-
*
|
|
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()` —
|
|
36
|
-
*
|
|
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
|
|
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[];
|
package/dist/tradingTimeScale.js
CHANGED
|
@@ -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)
|