@pond-ts/charts 0.43.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 +105 -1
- package/dist/BarChart.js +24 -1
- package/dist/BoxPlot.d.ts +80 -28
- package/dist/BoxPlot.js +67 -40
- package/dist/ChartContainer.d.ts +16 -8
- package/dist/ChartContainer.js +72 -26
- package/dist/Layers.js +31 -22
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/XAxis.js +7 -5
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +39 -9
- package/dist/data.d.ts +73 -30
- package/dist/data.js +79 -21
- package/dist/format.d.ts +4 -2
- package/dist/format.js +7 -3
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/dist/tradingTimeScale.d.ts +6 -0
- package/dist/tradingTimeScale.js +6 -0
- package/package.json +3 -3
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,34 +386,58 @@ 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
|
|
372
393
|
// briefly stale until the next pointer move — rare, and the bounds check below
|
|
373
394
|
// hides an out-of-plot crosshair meanwhile.
|
|
374
395
|
const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
|
|
375
|
-
// `cursor="region"` buckets
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
396
|
+
// `cursor="region"` snap buckets — the intervals the band snaps to (and a drag
|
|
397
|
+
// extends bucket by bucket over). Two sources, in precedence order:
|
|
398
|
+
//
|
|
399
|
+
// 1. **An explicit `cursorSequence`** (time axis only): realized over the view
|
|
400
|
+
// (a `Sequence` → `.bounded`; a `BoundedSequence` used as-is). A `Sequence`
|
|
401
|
+
// bucket is a *time* interval, so it's gated to a time axis — realizing time
|
|
402
|
+
// buckets over a value domain is meaningless (it would shade the whole plot).
|
|
403
|
+
// 2. **A bar/histogram layer's bins** (`binIntervals`, time **or** value axis):
|
|
404
|
+
// when no `cursorSequence` is set, the region cursor snaps to the bars —
|
|
405
|
+
// a histogram gets bin-aligned selection for free (the first bar layer that
|
|
406
|
+
// publishes bins wins; a plain histogram has exactly one).
|
|
407
|
+
//
|
|
408
|
+
// With neither, `undefined` ⇒ the freeform region cursor (raw-span drag).
|
|
383
409
|
const cursorBuckets = useMemo(() => {
|
|
384
|
-
if (cursorSequence
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
:
|
|
395
|
-
|
|
396
|
-
|
|
410
|
+
if (cursorSequence !== undefined && resolvedKind === 'time') {
|
|
411
|
+
if (!(cursorSequence instanceof Sequence))
|
|
412
|
+
return cursorSequence.intervals();
|
|
413
|
+
// `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
|
|
414
|
+
// contains the view start begins before it. Widen the realized range back by
|
|
415
|
+
// one bucket width so that covering bucket is included (a coarse calendar
|
|
416
|
+
// unit is bounded at ~a year; a fixed step uses its own width).
|
|
417
|
+
const back = cursorSequence.kind() === 'fixed'
|
|
418
|
+
? cursorSequence.stepMs()
|
|
419
|
+
: 366 * 86_400_000;
|
|
420
|
+
return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
|
|
421
|
+
}
|
|
422
|
+
// No sequence → snap to a bar/histogram layer's bins, if any (a value axis,
|
|
423
|
+
// or a time-axis histogram with no explicit sequence). `binIntervals` is only
|
|
424
|
+
// published by a vertical bar layer on a continuous axis, so this is a no-op
|
|
425
|
+
// for line/area/scatter rows and for a category axis.
|
|
426
|
+
//
|
|
427
|
+
// **First bar layer wins** — deliberately non-fatal, unlike `xCategories`
|
|
428
|
+
// (which *throws* when category rows disagree, because a mismatched slot order
|
|
429
|
+
// corrupts the shared band scale). Two overlaid histograms with different bins
|
|
430
|
+
// is a degenerate layout the region cursor just snaps to whichever registered
|
|
431
|
+
// first; a wrong snap grid is harmless where a wrong axis is not.
|
|
432
|
+
if (resolvedKind === 'time' || resolvedKind === 'value') {
|
|
433
|
+
for (const s of sources.values()) {
|
|
434
|
+
const bins = s.binIntervals?.() ?? null;
|
|
435
|
+
if (bins && bins.length > 0)
|
|
436
|
+
return bins;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}, [cursorSequence, d0, d1, resolvedKind, sources]);
|
|
397
441
|
// Emit { time, values } for an outside readout — recomputed as the cursor moves
|
|
398
442
|
// *or* the window slides under it (xScale change → new time at the same pixel).
|
|
399
443
|
// Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
|
|
@@ -455,6 +499,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
455
499
|
onHoverAnnotation,
|
|
456
500
|
onEditAnnotation,
|
|
457
501
|
formatTime,
|
|
502
|
+
xTickCount,
|
|
458
503
|
registerTrackerSource,
|
|
459
504
|
unregisterTrackerSource,
|
|
460
505
|
registerSelectable,
|
|
@@ -508,6 +553,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
508
553
|
onHoverAnnotation,
|
|
509
554
|
onEditAnnotation,
|
|
510
555
|
formatTime,
|
|
556
|
+
xTickCount,
|
|
511
557
|
registerTrackerSource,
|
|
512
558
|
unregisterTrackerSource,
|
|
513
559
|
registerSelectable,
|
package/dist/Layers.js
CHANGED
|
@@ -2,16 +2,17 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { Canvas } from './Canvas.js';
|
|
4
4
|
import { drawGrid, drawDividers, thinPixels } from './grid.js';
|
|
5
|
-
import { TimeRange } from 'pond-ts';
|
|
6
5
|
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
7
6
|
import { resolveSelection } from './select.js';
|
|
8
7
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
9
8
|
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
10
9
|
import { ContainerContext, LayersContext, RowContext, } from './context.js';
|
|
11
|
-
/**
|
|
12
|
-
* `TICK_COUNT`, `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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. */
|
|
15
16
|
const GRID_TICKS = 5;
|
|
16
17
|
/** Minimum px between session dividers — thins dense collapse points (e.g. a
|
|
17
18
|
* daily chart where every candle is a new session) so the axis never crowds. */
|
|
@@ -57,8 +58,10 @@ export function Layers({ children }) {
|
|
|
57
58
|
const background = container.theme.background;
|
|
58
59
|
const { grid: gridColor, gridDash } = container.theme.axis;
|
|
59
60
|
const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
|
|
60
|
-
// x geometry is shared and lives on the container (uniform across rows)
|
|
61
|
-
|
|
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;
|
|
62
65
|
const draw = useCallback((ctx, w, h) => {
|
|
63
66
|
if (background !== undefined) {
|
|
64
67
|
ctx.fillStyle = background;
|
|
@@ -72,7 +75,7 @@ export function Layers({ children }) {
|
|
|
72
75
|
const explicitY = tickValues.get(defaultAxisId);
|
|
73
76
|
// A category axis draws no vertical gridlines — a line through each bar
|
|
74
77
|
// centre reads as noise; the bars are the structure.
|
|
75
|
-
const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(
|
|
78
|
+
const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(xTickCount);
|
|
76
79
|
const xTicks = xTickVals.map((d) => xScale(+d));
|
|
77
80
|
const yTicks = gridY
|
|
78
81
|
? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
|
|
@@ -105,6 +108,7 @@ export function Layers({ children }) {
|
|
|
105
108
|
layers,
|
|
106
109
|
yScales,
|
|
107
110
|
xScale,
|
|
111
|
+
xTickCount,
|
|
108
112
|
defaultAxisId,
|
|
109
113
|
tickValues,
|
|
110
114
|
background,
|
|
@@ -260,13 +264,17 @@ export function Layers({ children }) {
|
|
|
260
264
|
}
|
|
261
265
|
return;
|
|
262
266
|
}
|
|
263
|
-
// Region-cursor drag-select (opt-in via `onRegionSelect
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
267
|
+
// Region-cursor drag-select (opt-in via `onRegionSelect`): anchor the
|
|
268
|
+
// selection at the press; the band then extends as the pointer moves (bucket
|
|
269
|
+
// by bucket with a sequence, freeform without), and release commits the span.
|
|
270
|
+
// Works on a continuous x axis — time **or** value (a category axis is
|
|
271
|
+
// excluded; its ordinal-slot select is a different gesture). A
|
|
272
|
+
// `regionSelectModifier` (only while `panZoom` is on) gates it behind the key
|
|
273
|
+
// so plain drag can still pan; otherwise it preempts pan (returns before the
|
|
274
|
+
// pan is armed below).
|
|
275
|
+
if (c.cursor === 'region' &&
|
|
276
|
+
c.onRegionSelect &&
|
|
277
|
+
(c.xKind === 'time' || c.xKind === 'value')) {
|
|
270
278
|
const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
|
|
271
279
|
if (!needsShift || e.shiftKey) {
|
|
272
280
|
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
@@ -417,7 +425,7 @@ export function Layers({ children }) {
|
|
|
417
425
|
/* ignore */
|
|
418
426
|
}
|
|
419
427
|
if (span)
|
|
420
|
-
c.onRegionSelect?.(
|
|
428
|
+
c.onRegionSelect?.([span.start, span.end]);
|
|
421
429
|
return;
|
|
422
430
|
}
|
|
423
431
|
if (c.creating !== null) {
|
|
@@ -623,12 +631,13 @@ export function Layers({ children }) {
|
|
|
623
631
|
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
624
632
|
};
|
|
625
633
|
})();
|
|
626
|
-
// `region` cursor (
|
|
627
|
-
// `cursorSequence` the band snaps to the bucket
|
|
628
|
-
// under a drag); with none
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
634
|
+
// `region` cursor (continuous x axis — time or value): shade the span under the
|
|
635
|
+
// pointer. With a `cursorSequence` (time axis only) the band snaps to the bucket
|
|
636
|
+
// (and extends bucket by bucket under a drag); with none — always the case on a
|
|
637
|
+
// value axis — it's the **freeform** case: a bare hover draws a plain line
|
|
638
|
+
// (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges map through
|
|
639
|
+
// `xScale`, so on a trading-time axis the band crops to live time.
|
|
640
|
+
const regionActive = parts.band && (container.xKind === 'time' || container.xKind === 'value');
|
|
632
641
|
const band = regionActive && cursorTime !== null
|
|
633
642
|
? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
|
|
634
643
|
: null;
|
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ValueSeries } from 'pond-ts';
|
|
2
|
+
import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
2
3
|
import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
|
|
3
|
-
export interface ScatterChartProps<S extends SeriesSchema> {
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
|
|
5
|
+
/**
|
|
6
|
+
* The source series. A `TimeSeries` scatters against the time axis; a
|
|
7
|
+
* `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
|
|
8
|
+
* for natively value-keyed data — IV marks keyed by strike) against its
|
|
9
|
+
* value axis — the container infers which from the data, no axis-type prop
|
|
10
|
+
* (mirrors `<LineChart>`). Either way the key / axis column supplies each
|
|
11
|
+
* point's x and `column` supplies y.
|
|
12
|
+
*
|
|
13
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
|
|
14
|
+
* so passing `series={s.byValue('dist')}` inline re-registers this layer
|
|
15
|
+
* every render — memoize the projection (`useMemo`) on a frequently
|
|
16
|
+
* re-rendering chart.
|
|
17
|
+
*/
|
|
18
|
+
series: TimeSeries<S> | ValueSeries<VS>;
|
|
6
19
|
/** Name of the numeric value column — each point's y. */
|
|
7
20
|
column: string;
|
|
8
21
|
/**
|
|
@@ -24,6 +37,11 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
24
37
|
* the key the controlled `selected` echo, dedup, and (later) multi-select all
|
|
25
38
|
* match on — so a selection survives a data update where a sample `key` goes
|
|
26
39
|
* stale.
|
|
40
|
+
*
|
|
41
|
+
* A point's identity within the series is its **x** (key / axis value). The
|
|
42
|
+
* key contract allows duplicate x's (equal timestamps; a value-axis plateau
|
|
43
|
+
* from `byValue('cumDist')`) — points sharing an x share identity, so
|
|
44
|
+
* selecting one highlights the last drawn point at that x.
|
|
27
45
|
*/
|
|
28
46
|
id?: string;
|
|
29
47
|
/**
|
|
@@ -59,6 +77,14 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
59
77
|
* dense scatter is noise; this is for a handful of called-out marks.
|
|
60
78
|
*/
|
|
61
79
|
label?: string | boolean;
|
|
80
|
+
/**
|
|
81
|
+
* A **pixel** shift applied to every point's x — zoom-stable. **Default `0`.**
|
|
82
|
+
* For pairing marks that share a key side by side (a call and a put mark at one
|
|
83
|
+
* strike: `offset={-4}` / `offset={+4}`). Pairs with `<BoxPlot offset>`; on the
|
|
84
|
+
* scatter the shift is exact — both the draw and the click hit-test move
|
|
85
|
+
* together, so a nudged point still selects.
|
|
86
|
+
*/
|
|
87
|
+
offset?: number;
|
|
62
88
|
/**
|
|
63
89
|
* @internal Declaration position among the `<Layers>` children, injected by
|
|
64
90
|
* `Layers` so z-order follows JSX order. Do not set.
|
|
@@ -66,7 +92,8 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
66
92
|
index?: number;
|
|
67
93
|
}
|
|
68
94
|
/**
|
|
69
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
95
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
96
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
70
97
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
71
98
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
72
99
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -92,5 +119,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
92
119
|
* </Layers>
|
|
93
120
|
* ```
|
|
94
121
|
*/
|
|
95
|
-
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
122
|
+
export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
|
|
96
123
|
//# sourceMappingURL=ScatterChart.d.ts.map
|
package/dist/ScatterChart.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ValueSeries } from 'pond-ts';
|
|
3
|
+
import { fromTimeSeries, fromValueSeries } from './data.js';
|
|
3
4
|
import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
|
|
4
5
|
import { resolveEncoding, } from './encoding.js';
|
|
5
6
|
import { ContainerContext, LayersContext } from './context.js';
|
|
6
7
|
import { useSlotKey } from './use-slot-key.js';
|
|
7
8
|
/**
|
|
8
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
9
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
10
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
9
11
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
10
12
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
11
13
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -31,7 +33,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
31
33
|
* </Layers>
|
|
32
34
|
* ```
|
|
33
35
|
*/
|
|
34
|
-
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
|
|
36
|
+
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
|
|
35
37
|
const container = useContext(ContainerContext);
|
|
36
38
|
if (container === null) {
|
|
37
39
|
throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
|
|
@@ -40,7 +42,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
40
42
|
if (layers === null) {
|
|
41
43
|
throw new Error('<ScatterChart> must be rendered inside a <Layers>');
|
|
42
44
|
}
|
|
43
|
-
const cs = useMemo(() =>
|
|
45
|
+
const cs = useMemo(() => series instanceof ValueSeries
|
|
46
|
+
? fromValueSeries(series, column)
|
|
47
|
+
: fromTimeSeries(series, column), [series, column]);
|
|
44
48
|
// Styling: semantic identifier → theme scatter style. The single styling
|
|
45
49
|
// channel for the base mark.
|
|
46
50
|
const { scatter } = container.theme;
|
|
@@ -53,13 +57,26 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
53
57
|
// pulls a named numeric column to a Float64Array (gaps NaN) — the same path
|
|
54
58
|
// fromTimeSeries uses; an unknown / non-numeric column throws there (eager,
|
|
55
59
|
// so a typo surfaces at render, not silently as base-styled points).
|
|
56
|
-
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) =>
|
|
60
|
+
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => series instanceof ValueSeries
|
|
61
|
+
? fromValueSeries(series, col).y
|
|
62
|
+
: fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
|
|
57
63
|
// Per-point label accessor: a column name reads that field, `true` reads the
|
|
58
64
|
// plotted column, anything else (false / omitted) ⇒ no labels.
|
|
59
65
|
const labelAt = useMemo(() => {
|
|
60
66
|
if (label === undefined || label === false)
|
|
61
67
|
return undefined;
|
|
62
68
|
const field = label === true ? column : label;
|
|
69
|
+
if (series instanceof ValueSeries) {
|
|
70
|
+
// Columnar read — a ValueSeries has no per-row events. The field is a
|
|
71
|
+
// runtime string, cast onto the schema-literal column name (the same
|
|
72
|
+
// pattern as data.ts' readValueColumn). A gap or an unknown column reads
|
|
73
|
+
// undefined => no label at that point.
|
|
74
|
+
const col = series.column(field);
|
|
75
|
+
return (i) => {
|
|
76
|
+
const v = col?.read(i);
|
|
77
|
+
return v === undefined || v === null ? undefined : String(v);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
63
80
|
return (i) => {
|
|
64
81
|
// series.at(i) is O(1) per row (columnar eventAt cache), so a label per
|
|
65
82
|
// point stays cheap. The field is a runtime string → cast off the literal-
|
|
@@ -71,26 +88,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
71
88
|
return v === undefined || v === null ? undefined : String(v);
|
|
72
89
|
};
|
|
73
90
|
}, [label, column, series]);
|
|
74
|
-
// The point's stable key is its event begin (epoch ms)
|
|
75
|
-
//
|
|
91
|
+
// The point's stable key is its x — the event begin (epoch ms) on a time
|
|
92
|
+
// axis, the axis value on a value axis; either way it's cs.x[i], the key
|
|
93
|
+
// column's begin buffer. Used for selection identity.
|
|
76
94
|
const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
|
|
77
95
|
const entry = useMemo(() => ({
|
|
78
96
|
layer: {
|
|
79
97
|
yExtent: () => scatterExtent(cs),
|
|
80
|
-
|
|
98
|
+
// The container infers the shared x scale's kind + auto-fit domain from
|
|
99
|
+
// its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
|
|
100
|
+
xKind: series instanceof ValueSeries ? 'value' : 'time',
|
|
81
101
|
xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
|
|
82
|
-
sampleAt: (
|
|
102
|
+
sampleAt: (x) => {
|
|
83
103
|
// No readout past the data (tracker policy — the dot snaps to a drawn
|
|
84
|
-
// mark, never extrapolates past the span); bounds from the
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
time > cs.x[cs.length - 1]) {
|
|
104
|
+
// mark, never extrapolates past the span); bounds from the columnar x
|
|
105
|
+
// axis (epoch ms or axis value — the bisect doesn't care).
|
|
106
|
+
if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
|
|
88
107
|
return [];
|
|
89
108
|
}
|
|
90
109
|
// Nearest *drawn* point by index (skips gaps) — O(log N). Reading by
|
|
91
110
|
// index gives the value, the snap-to x, and the encoded colour in one
|
|
92
111
|
// shot, so the readout swatch matches the mark the user sees.
|
|
93
|
-
const i = nearestIndex(cs,
|
|
112
|
+
const i = nearestIndex(cs, x);
|
|
94
113
|
if (i < 0)
|
|
95
114
|
return [];
|
|
96
115
|
return [
|
|
@@ -108,9 +127,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
108
127
|
...(id === undefined
|
|
109
128
|
? {}
|
|
110
129
|
: {
|
|
111
|
-
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
|
|
130
|
+
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
|
|
112
131
|
}),
|
|
113
|
-
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
|
|
132
|
+
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
|
|
114
133
|
},
|
|
115
134
|
axisId: axis,
|
|
116
135
|
index,
|
|
@@ -126,6 +145,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
126
145
|
labelAt,
|
|
127
146
|
font,
|
|
128
147
|
container.selected,
|
|
148
|
+
offset,
|
|
129
149
|
axis,
|
|
130
150
|
index,
|
|
131
151
|
]);
|
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/box.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ import type { Scale } from './line.js';
|
|
|
3
3
|
import type { BoxStyle } from './theme.js';
|
|
4
4
|
/**
|
|
5
5
|
* The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
|
|
6
|
-
* whisker and highest `upper` whisker over keys
|
|
7
|
-
*
|
|
8
|
-
* matching what {@link drawBox}
|
|
6
|
+
* whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
|
|
7
|
+
* (a full box needs all five quantiles; a range-only box just `lower`/`upper`) —
|
|
8
|
+
* or `null` if none are. Gap keys are excluded, matching what {@link drawBox}
|
|
9
|
+
* draws, so they don't drag the y-domain.
|
|
9
10
|
*
|
|
10
11
|
* Only `lower`/`upper` bound the extent: they are the outermost reach of a key
|
|
11
12
|
* (the whisker ends), so `q1`/`median`/`q3` lie within `[lower, upper]` for any
|
|
@@ -44,16 +45,32 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
|
|
|
44
45
|
* reads darker on a light ground, brighter on a dark one), no stems/outline.
|
|
45
46
|
* - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
|
|
46
47
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* **Range-only** (`box.hasBox === false` — no `q1`/`q3`): there's no body, so
|
|
49
|
+
* `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
|
|
50
|
+
* the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
|
|
51
|
+
* `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
|
|
52
|
+
* carries no `median` (`hasMedian === false`).
|
|
49
53
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* Then, if `showMedian` (and a median is present), the median line on top. Fills
|
|
55
|
+
* are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
|
|
56
|
+
* `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
|
|
57
|
+
* `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
|
|
58
|
+
* fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
|
|
59
|
+
* box width.
|
|
60
|
+
*
|
|
61
|
+
* **Gap-aware**: a key whose present quantiles aren't all finite is skipped
|
|
62
|
+
* entirely (no partial box) — the same contract as a band gap.
|
|
52
63
|
*
|
|
53
64
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
54
65
|
* beyond the `barSpanPx` tuple.
|
|
55
66
|
*/
|
|
56
|
-
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean): void;
|
|
57
|
-
/**
|
|
67
|
+
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number): void;
|
|
68
|
+
/**
|
|
69
|
+
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|
|
70
|
+
* `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
|
|
71
|
+
* box has a body (`hasBox !== false`), `median` only when it has a centre line
|
|
72
|
+
* (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
|
|
73
|
+
* wherever `lower`/`upper` are finite, and a full box still needs all five.
|
|
74
|
+
*/
|
|
58
75
|
export declare function isFiniteBox(box: BoxSeries, i: number): boolean;
|
|
59
76
|
//# sourceMappingURL=box.d.ts.map
|