pond-ts 0.70.0 → 0.72.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/AGENTS.md +3 -1
- package/API.md +30 -31
- package/CHANGELOG.md +143 -1
- package/README.md +3 -1
- package/dist/batch/operators/join.d.ts +37 -0
- package/dist/batch/operators/join.js +252 -0
- package/dist/batch/time-series.d.ts +13 -0
- package/dist/batch/time-series.js +5 -40
- package/dist/live/series-store.js +6 -6
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -159,6 +159,7 @@ import {
|
|
|
159
159
|
Layers,
|
|
160
160
|
LineChart,
|
|
161
161
|
YAxis,
|
|
162
|
+
CrosshairCursor,
|
|
162
163
|
} from '@pond-ts/charts';
|
|
163
164
|
|
|
164
165
|
const [live, snap] = useLiveSeries({
|
|
@@ -167,7 +168,8 @@ const [live, snap] = useLiveSeries({
|
|
|
167
168
|
retention: { maxAge: '10m' },
|
|
168
169
|
});
|
|
169
170
|
|
|
170
|
-
<ChartContainer width={800}
|
|
171
|
+
<ChartContainer width={800} panZoom>
|
|
172
|
+
<CrosshairCursor />
|
|
171
173
|
<ChartRow height={240}>
|
|
172
174
|
<YAxis id="ms" />
|
|
173
175
|
<Layers>
|
package/API.md
CHANGED
|
@@ -251,17 +251,17 @@ Types: `UseSnapshotOptions`, `SnapshotSource` (structural — covers
|
|
|
251
251
|
|
|
252
252
|
### Components — layout & axes
|
|
253
253
|
|
|
254
|
-
| Component | Key props
|
|
255
|
-
| --------------------------- |
|
|
256
|
-
| `ChartContainer` | `width`, `range?`, `theme?`, `
|
|
257
|
-
| `ChartRow` | `height
|
|
258
|
-
| `Layers` | children
|
|
259
|
-
| `YAxis` | `id` (req), `side?`, `scale?` (`'linear'` \| `'log'` \| `'symlog'`), `linearWindow?`, `min?`/`max?`, `format?`, `width?`, `hide?`
|
|
260
|
-
| `XAxis` | `side?`, `label?`, `format?`, `ticks?`, `transform?`, `dateStyle?`, `timeZone?` (this strip in another IANA zone)
|
|
261
|
-
| `TimeAxis` / `CategoryAxis` | (XAxis props)
|
|
262
|
-
| `Canvas` | `width`, `height`, `draw`
|
|
263
|
-
| `Selector` | `enabled?` (default `true`), `selected?` (mark \| set), `hovered?`, `onSelect?`, `onHover?`, `children?`
|
|
264
|
-
| `MultiSelector` | `enabled?`, `selected?`, `hovered?`, `sequence?`, `onSelect?`, `onHover?`, `children?`
|
|
254
|
+
| Component | Key props | Purpose | Source |
|
|
255
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
256
|
+
| `ChartContainer` | `width`, `range?`, `theme?`, `panZoom?`, `xScale?`, `bounds?`, `showAxis?`, `calendar?`, `timeZone?`, `origin?`, `maxBandWidth?`/`bandAlign?`, `onTrackerChanged?`, `onDrawStats?` | Root: shared x-scale, interactions, annotations; `timeZone` renders the time axis in an IANA zone (default: viewer-local; a `calendar.timeZone` supplies the default) | `packages/charts/src/ChartContainer.tsx` |
|
|
257
|
+
| `ChartRow` | `height` | One stacked plot band; owns its y-axes | `packages/charts/src/ChartRow.tsx` |
|
|
258
|
+
| `Layers` | children | Mandatory z-stack inside a row (back-to-front) | `packages/charts/src/Layers.tsx` |
|
|
259
|
+
| `YAxis` | `id` (req), `side?`, `scale?` (`'linear'` \| `'log'` \| `'symlog'`), `linearWindow?`, `min?`/`max?`, `format?`, `width?`, `hide?` | Y-axis gutter; layers bind via their `axis` prop | `packages/charts/src/YAxis.tsx` |
|
|
260
|
+
| `XAxis` | `side?`, `label?`, `format?`, `ticks?`, `transform?`, `dateStyle?`, `timeZone?` (this strip in another IANA zone) | Placeable x-axis strip; kind inferred from data | `packages/charts/src/XAxis.tsx` |
|
|
261
|
+
| `TimeAxis` / `CategoryAxis` | (XAxis props) | Thin `XAxis` presets | `packages/charts/src/TimeAxis.tsx`, `CategoryAxis.tsx` |
|
|
262
|
+
| `Canvas` | `width`, `height`, `draw` | Low-level DPR-aware canvas primitive | `packages/charts/src/Canvas.tsx` |
|
|
263
|
+
| `Selector` | `enabled?` (default `true`), `selected?` (mark \| set), `hovered?`, `onSelect?`, `onHover?`, `children?` | Wraps its scope; mounting enables click-select and owns the state it drives (RFC A10) | `packages/charts/src/selectors.tsx` |
|
|
264
|
+
| `MultiSelector` | `enabled?`, `selected?`, `hovered?`, `sequence?`, `onSelect?`, `onHover?`, `children?` | Sweep-select superset of `Selector`: drag sweeps marks, release reports `(hits, modifiers, spans)` — plural, one per swept layer (RFC A5.2) | `packages/charts/src/selectors.tsx` |
|
|
265
265
|
|
|
266
266
|
### Components — draw layers
|
|
267
267
|
|
|
@@ -303,25 +303,24 @@ schema columns). Because the union splits per series _kind_, a value typed as
|
|
|
303
303
|
|
|
304
304
|
### Components — cursors (mounted presets)
|
|
305
305
|
|
|
306
|
-
|
|
307
|
-
as a child of `<ChartContainer>` (the default for
|
|
308
|
-
`<ChartRow>` (the per-row override). Render-only
|
|
309
|
-
gesture-owning cursor (`Crosshair`/`Range`) per scope. The
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
|
318
|
-
|
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
324
|
-
| `RangeCursor` | `sequence?`, `onDragRelease?`, `enableDrag?`, `dragModifier?` | The hover-time band + the drag: release fires once with a `RangeSpan`, then reverts (`"region"` + `onRegionSelect` successor) | `packages/charts/src/cursors.tsx` |
|
|
306
|
+
Cursor presets (interaction RFC §4/A4.1). **A chart shows a cursor only when
|
|
307
|
+
one is mounted** — mount one as a child of `<ChartContainer>` (the default for
|
|
308
|
+
every row) or inside a `<ChartRow>` (the per-row override). Render-only
|
|
309
|
+
presets stack; one gesture-owning cursor (`Crosshair`/`Range`) per scope. The
|
|
310
|
+
first mounted cursor that sets `format` shapes the chart's readout channel.
|
|
311
|
+
The underlying `CursorSpec` contract stays unpublished (Q3); every drag claim
|
|
312
|
+
on the plot (annotation-create, the range drag, pan) is arbitrated by one
|
|
313
|
+
brush recognizer with a documented precedence (`src/brush.tsx`, RFC
|
|
314
|
+
A1.5/A2.7).
|
|
315
|
+
|
|
316
|
+
| Component | Key props | Purpose | Source |
|
|
317
|
+
| ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- |
|
|
318
|
+
| `LineCursor` | `showTime?`, `format?` | The synced vertical line | `packages/charts/src/cursors.tsx` |
|
|
319
|
+
| `PointCursor` | `showTime?`, `format?` | A dot on each series at the cursor | `packages/charts/src/cursors.tsx` |
|
|
320
|
+
| `InlineCursor` | `showTime?`, `format?` | Dots + a value chip beside each | `packages/charts/src/cursors.tsx` |
|
|
321
|
+
| `FlagCursor` | `showTime?`, `format?` | Dots + staffed value flags stacked at the top | `packages/charts/src/cursors.tsx` |
|
|
322
|
+
| `CrosshairCursor` | `snap?`, `showTime?`, `format?`, `onSnap?` | The inspection reticle: dashed cross, centre dot in the snapped series' colour, y value pill, x time pill; `onSnap` reports the snapped series + point | `packages/charts/src/cursors.tsx` |
|
|
323
|
+
| `RangeCursor` | `sequence?`, `onDragRelease?`, `enableDrag?`, `dragModifier?` | The hover-time band + the drag: release fires once with a `RangeSpan`, then reverts; on a category axis the band shades the slot and the drag is off | `packages/charts/src/cursors.tsx` |
|
|
325
324
|
|
|
326
325
|
### Components — standalone row lists (DOM tables, no `<ChartContainer>`)
|
|
327
326
|
|
|
@@ -398,8 +397,8 @@ Series shapes (same file): `ChartSeries`, `BandSeries`, `BoxSeries`,
|
|
|
398
397
|
| `scaleBand` / `ScaleBand` | Ordinal slot scale for the category axis | `packages/charts/src/bandScale.ts` |
|
|
399
398
|
| `GapMode` | `'none' \| 'empty' \| 'dashed' \| 'step' \| 'fade'` (Line/Area `gaps` prop) | `packages/charts/src/gaps.ts` |
|
|
400
399
|
| `DecimateOption` | `<LineChart decimate>` — M4 viewport decimation (`bool \| { threshold }`) | `packages/charts/src/decimate.ts` |
|
|
401
|
-
| `CursorMode` | `'none' \| 'line' \| 'point' \| 'inline' \| 'flag' \| 'crosshair' \| 'region'` | `packages/charts/src/context.ts` |
|
|
402
400
|
| `TrackerInfo` / `TrackerSample` | Hover readout payload (`onTrackerChanged`) | `packages/charts/src/context.ts` |
|
|
401
|
+
| `CursorSnap` | The point a `<CrosshairCursor>` snapped to (`onSnap`): a `TrackerSample` + `axisId` + `formatted` | `packages/charts/src/context.ts` |
|
|
403
402
|
| `AnnotationKind` / `CreateSpec` | Annotation identity + draw-gesture payload (`onCreate`) | `packages/charts/src/context.ts` |
|
|
404
403
|
| `SelectInfo` | Selection/hover payload (`Selector`/`MultiSelector` `onSelect`/`onHover`) | `packages/charts/src/context.ts` |
|
|
405
404
|
| `SelectModifiers` | Keyboard modifiers on a click, 2nd arg to `onSelect` | `packages/charts/src/context.ts` |
|
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
|
|
|
8
8
|
under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
|
|
9
9
|
include new features and type-level changes; patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.72.0...HEAD
|
|
12
|
+
[0.72.0]: https://github.com/pond-ts/pond/compare/v0.71.0...v0.72.0
|
|
13
|
+
[0.71.0]: https://github.com/pond-ts/pond/compare/v0.70.0...v0.71.0
|
|
12
14
|
[0.70.0]: https://github.com/pond-ts/pond/compare/v0.69.0...v0.70.0
|
|
13
15
|
[0.69.0]: https://github.com/pond-ts/pond/compare/v0.68.0...v0.69.0
|
|
14
16
|
[0.68.0]: https://github.com/pond-ts/pond/compare/v0.67.0...v0.68.0
|
|
@@ -73,6 +75,146 @@ include new features and type-level changes; patch bumps are strictly additive.
|
|
|
73
75
|
|
|
74
76
|
## [Unreleased]
|
|
75
77
|
|
|
78
|
+
## [0.72.0] — 2026-10-08
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- `pond-ts`: **`join` and `joinMany` work on columns now, not events** — 20
|
|
83
|
+
to 190× faster in the cases below, with the same output. `join` used to
|
|
84
|
+
build an event for every row on both sides, merge them row by row and
|
|
85
|
+
rebuild the columns. Now it walks the two key columns once, then either
|
|
86
|
+
adopts each column as is or gathers it in one pass. A side's value columns
|
|
87
|
+
are adopted as is whenever all its rows land in the output once and in order:
|
|
88
|
+
always the left side of a `left` join, the right side of a `right` join, and
|
|
89
|
+
both sides when the keys match one for one (`joinMany` over a shared grid).
|
|
90
|
+
One year of 1-minute bars, ~97.5k rows a side, `type: 'left'` unless noted
|
|
91
|
+
(`packages/core/scripts/perf-join.mjs`):
|
|
92
|
+
|
|
93
|
+
| Value columns per side | Before | After |
|
|
94
|
+
| ------------------------ | -------- | -------- |
|
|
95
|
+
| 59 and 59 | 1,868 ms | 24 ms |
|
|
96
|
+
| 5 and 5 | 168 ms | 4.2 ms |
|
|
97
|
+
| 59 and 2 | 766 ms | 4.1 ms |
|
|
98
|
+
| `joinMany`, 4 × 5, outer | 457 ms | 15–20 ms |
|
|
99
|
+
|
|
100
|
+
A left join now costs about as much as the **other** side is wide. To bring
|
|
101
|
+
across only the columns you read, narrow that side first; `select` and
|
|
102
|
+
`rename` don't copy:
|
|
103
|
+
`bars.join(spy.select('close').rename({ close: 'spy' }), { type: 'left' })`.
|
|
104
|
+
|
|
105
|
+
One visible difference: on a row with no match, `event.data()` now lists the
|
|
106
|
+
other side's fields as `undefined`, as every other operator's events do. It
|
|
107
|
+
used to leave them out. `get()` returns `undefined` either way.
|
|
108
|
+
|
|
109
|
+
## [0.71.0] — 2026-09-24
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- `@pond-ts/charts`: **`format` on `<LineCursor>`, `<PointCursor>`,
|
|
114
|
+
`<InlineCursor>` and `<FlagCursor>`** — the readout format that used to be
|
|
115
|
+
the container's `cursorFormat`, now on whichever cursor you mount (it was
|
|
116
|
+
already on `<CrosshairCursor>`). The chart still has one readout channel:
|
|
117
|
+
the first mounted cursor that sets `format` shapes it.
|
|
118
|
+
|
|
119
|
+
- `@pond-ts/charts`: **`<CrosshairCursor onSnap>`** — tells you what the
|
|
120
|
+
crosshair is snapped to: the series (`label`, `color`, `axisId`) and the
|
|
121
|
+
point (`x`, `value`, `formatted`, plus `readout` when the layer has one), as
|
|
122
|
+
a new exported **`CursorSnap`** type. Fires only when the snapped point
|
|
123
|
+
changes, and with `null` when the pointer leaves. With `snap={false}` the
|
|
124
|
+
reticle follows the pointer rather than a series, so it stays `null`; a
|
|
125
|
+
reticle drawn by a controlled `trackerPosition` with no pointer on the chart
|
|
126
|
+
also reports `null`. Story `Cursors/Crosshair / SnapReadout`.
|
|
127
|
+
|
|
128
|
+
- `@pond-ts/charts`: **`<CrosshairCursor>` snaps to box plots.** Its vertical
|
|
129
|
+
line lands on the centre of the box under the pointer and its horizontal
|
|
130
|
+
line on the quantile nearest the pointer (`upper` / `q3` / `median` / `q1` /
|
|
131
|
+
`lower`), with that value on the y-axis pill and reported by `onSnap`. A box
|
|
132
|
+
used to be skipped by the crosshair entirely: the line sat wherever the
|
|
133
|
+
pointer was and there was no value to read. The flag cursor still shows the
|
|
134
|
+
box's one consolidated flag, and the point / inline / flag cursors still draw
|
|
135
|
+
no per-quantile dots on a box. In a row with a box and a line, the vertical
|
|
136
|
+
line snaps to whichever of the two is drawn on top, the same rule as for two
|
|
137
|
+
lines. Story `Cursors/Crosshair / BoxPlot`.
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
|
|
141
|
+
- `@pond-ts/charts`: the **crosshair's centre dot is drawn in the snapped
|
|
142
|
+
series' colour** rather than the cursor ink, so the reticle shows which line
|
|
143
|
+
it is reading. The free reticle (`snap={false}`) has no series under it and
|
|
144
|
+
keeps the cursor ink.
|
|
145
|
+
|
|
146
|
+
- **charts:** **`<AreaChart>` fills to zero by default.** An omitted `baseline`
|
|
147
|
+
used to rest the fill on the bottom of the plot, which on auto-fit data sits
|
|
148
|
+
just under the lowest value — so a series running 50–90 drew 60 as a sliver
|
|
149
|
+
and 90 as a slab several times its size, and the fill's height said nothing
|
|
150
|
+
about the value. The default is now `0`: zero is pulled into the auto-fit
|
|
151
|
+
domain and each fill is as tall as its value, the usual reading of an area.
|
|
152
|
+
A number still sets another reference level. The old look is one prop away:
|
|
153
|
+
**`baseline="floor"`** rests the fill on the bottom of the plot and adds
|
|
154
|
+
nothing to the domain — for a price or an elevation profile, where starting
|
|
155
|
+
at zero would flatten the shape. **Migration:** an `<AreaChart>` with no
|
|
156
|
+
`baseline` whose data sits far from zero will now show zero on its axis; add
|
|
157
|
+
`baseline="floor"` to keep the previous rendering. Charts that already wrote
|
|
158
|
+
`baseline={0}`, or whose axis already started at zero, are unchanged. On a
|
|
159
|
+
log axis nothing changes: zero has no position there, so it rests on the
|
|
160
|
+
floor and is not pulled into the domain. On an axis pinned above zero
|
|
161
|
+
(`<YAxis min={40}>`) the baseline is clamped to the axis floor, as a bar's is,
|
|
162
|
+
so the fill's fade stays on the plot.
|
|
163
|
+
|
|
164
|
+
### Removed
|
|
165
|
+
|
|
166
|
+
- `@pond-ts/charts` (**breaking**): **the old cursor props are gone — a chart
|
|
167
|
+
shows a cursor only when you mount one.** Removed: `<ChartContainer>`'s
|
|
168
|
+
`cursor`, `cursorTime`, `crosshairSnap`, `cursorFormat`, `cursorSequence`,
|
|
169
|
+
`onRegionSelect` and `regionSelectModifier`, `<ChartRow cursor>`, and the
|
|
170
|
+
`CursorMode` type. They were deprecated in 0.58.0. The behaviour change that
|
|
171
|
+
matters most: a chart with no cursor component used to get a vertical line
|
|
172
|
+
cursor anyway; it now gets **no cursor** (hover still reports through
|
|
173
|
+
`onTrackerChanged`). Closes
|
|
174
|
+
[#647](https://github.com/pond-ts/pond/issues/647). **Migration:**
|
|
175
|
+
|
|
176
|
+
| Before | After |
|
|
177
|
+
| ------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
178
|
+
| no `cursor` prop (the implicit line) | `<LineCursor />` (not on a `<MultiSelector>` row — see below) |
|
|
179
|
+
| `cursor="line" \| "point" \| "inline" \| "flag"` | `<LineCursor />` / `<PointCursor />` / `<InlineCursor />` / `<FlagCursor />` |
|
|
180
|
+
| `cursor="crosshair"` + `crosshairSnap={false}` | `<CrosshairCursor snap={false} />` |
|
|
181
|
+
| `cursorTime` | `showTime` on the cursor |
|
|
182
|
+
| `cursorFormat="…"` | `format="…"` on the cursor |
|
|
183
|
+
| `cursor="region"` + `cursorSequence={seq}` | `<RangeCursor sequence={seq} />` |
|
|
184
|
+
| `onRegionSelect={([a, b]) => …}` | `<RangeCursor onDragRelease={({ x: [a, b] }) => …} />` |
|
|
185
|
+
| `regionSelectModifier="shift"` | `<RangeCursor dragModifier="shift" />` |
|
|
186
|
+
| `cursor="none"` | mount nothing |
|
|
187
|
+
| `<ChartRow cursor="…">` | mount the cursor inside that `<ChartRow>` |
|
|
188
|
+
|
|
189
|
+
Mount the cursor as a child of `<ChartContainer>` for every row, or inside
|
|
190
|
+
one `<ChartRow>` for that row only. A row that should have no cursor while
|
|
191
|
+
its siblings have one: mount the cursors per row instead of at the
|
|
192
|
+
container. **Don't add `<LineCursor />` to a row with a `<MultiSelector>`**:
|
|
193
|
+
there the selector's resting band is the cursor (as it already was under the
|
|
194
|
+
implicit line), and any mounted cursor replaces the band.
|
|
195
|
+
|
|
196
|
+
### Fixed
|
|
197
|
+
|
|
198
|
+
- `@pond-ts/charts`: **`<RangeCursor>` on a category axis now shades the bar
|
|
199
|
+
under the pointer.** It used to draw nothing there, so mounting one left the
|
|
200
|
+
row with no cursor at all. The band covers the whole slot, the way a
|
|
201
|
+
bucketed band covers a bucket on a time axis. The drag stays off on a
|
|
202
|
+
category axis (`onDragRelease` never fires there, and the chart now warns
|
|
203
|
+
in development when it is wired); dragging across bars to get them back is
|
|
204
|
+
what `<MultiSelector>` does. Story `Cursors/Range / CategoryAxis`.
|
|
205
|
+
|
|
206
|
+
- **charts:** **A selectable `<AreaChart>` on a log axis with `baseline={0}`
|
|
207
|
+
counted every point over its x span as a hit.** Zero maps to `NaN` on a log
|
|
208
|
+
scale; the draw already fell back to the axis floor, but the hit test used
|
|
209
|
+
the `NaN` pixel, and a `NaN` bound fails neither range check. It now resolves
|
|
210
|
+
the baseline the same way the draw does.
|
|
211
|
+
- **charts:** **`<AreaChart baseline={0}>` on an auto-fit log axis clipped
|
|
212
|
+
most of its series.** The zero baseline was pulled into the extent, which
|
|
213
|
+
left the log fit with no positive low end, so the domain collapsed around the
|
|
214
|
+
largest value (data from 10 to 1e5 fitted `[1e4, 1e6]`). A baseline at or
|
|
215
|
+
below zero is now left out of a log axis's fit. A layer's `yExtent` receives
|
|
216
|
+
the axis's scale kind for this.
|
|
217
|
+
|
|
76
218
|
## [0.70.0] — 2026-09-18
|
|
77
219
|
|
|
78
220
|
### Added
|
package/README.md
CHANGED
|
@@ -145,13 +145,15 @@ import {
|
|
|
145
145
|
Layers,
|
|
146
146
|
LineChart,
|
|
147
147
|
YAxis,
|
|
148
|
+
CrosshairCursor,
|
|
148
149
|
} from '@pond-ts/charts';
|
|
149
150
|
|
|
150
151
|
// `bands` is the baseline() result from the batch quick start:
|
|
151
152
|
// cpu + avg / sd / upper / lower columns.
|
|
152
153
|
export function CpuChart({ width }: { width: number }) {
|
|
153
154
|
return (
|
|
154
|
-
<ChartContainer width={width}
|
|
155
|
+
<ChartContainer width={width} panZoom>
|
|
156
|
+
<CrosshairCursor />
|
|
155
157
|
<ChartRow height={240}>
|
|
156
158
|
<YAxis id="cpu" format=".0%" />
|
|
157
159
|
<Layers>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ColumnSchema, ColumnarStore } from '../../columnar/index.js';
|
|
2
|
+
import type { JoinType } from '../../schema/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* **Column-native exact-key `join`** ([PND-JOINCOL]). Merge-walks the two
|
|
5
|
+
* key columns once into a pair of row-match indices — output row `r` takes
|
|
6
|
+
* left row `leftIdx[r]` and right row `rightIdx[r]`, with `-1` meaning "no
|
|
7
|
+
* row on that side" — then builds every output column from those indices:
|
|
8
|
+
*
|
|
9
|
+
* - **Pass-through.** When one side's index is the identity (each of its
|
|
10
|
+
* rows appears once, in order, with nothing interleaved), that side's
|
|
11
|
+
* value columns are adopted by reference — and, on the left, the key
|
|
12
|
+
* column too. This is always true of the primary in a `left` join and of
|
|
13
|
+
* the other side in a `right` join, and it is true of **both** sides when
|
|
14
|
+
* the two keys match one for one — the `joinMany` over a shared grid case.
|
|
15
|
+
* - **Gather.** Otherwise each column is one `sliceByIndices` over the match
|
|
16
|
+
* index; a `-1` slot comes out missing through the column's validity, the
|
|
17
|
+
* substrate's existing out-of-range gather contract.
|
|
18
|
+
*
|
|
19
|
+
* No `Event` is materialised on either side or in the output.
|
|
20
|
+
*
|
|
21
|
+
* **Semantics are the event walk's, exactly.** The key comparison is
|
|
22
|
+
* `compareEventKeys` / `Interval.compare` restated over the buffers —
|
|
23
|
+
* `begin`, then `end`, then (interval keys only) `compareIntervalValues` on
|
|
24
|
+
* the labels — and the walk pairs equal keys **one to one** in order: a key
|
|
25
|
+
* that repeats `a` times on the left and `b` times on the right yields
|
|
26
|
+
* `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a·b`. A matched
|
|
27
|
+
* row carries the **left** key.
|
|
28
|
+
*
|
|
29
|
+
* Complexity: O(N + M) for the walk, plus O(R) per gathered column (R output
|
|
30
|
+
* rows). Pass-through columns cost nothing.
|
|
31
|
+
*
|
|
32
|
+
* The caller has already checked that the key kinds agree and that no value
|
|
33
|
+
* column name appears on both sides; `outSchema` is the left key column
|
|
34
|
+
* followed by the left then the right value columns.
|
|
35
|
+
*/
|
|
36
|
+
export declare function joinOp(left: ColumnarStore<ColumnSchema>, right: ColumnarStore<ColumnSchema>, joinType: JoinType, outSchema: ColumnSchema): ColumnarStore<ColumnSchema>;
|
|
37
|
+
//# sourceMappingURL=join.d.ts.map
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { ColumnarStore, Float64Column, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, stringColumnFromArray, } from '../../columnar/index.js';
|
|
2
|
+
import { compareIntervalValues } from '../../core/temporal.js';
|
|
3
|
+
/**
|
|
4
|
+
* **Column-native exact-key `join`** ([PND-JOINCOL]). Merge-walks the two
|
|
5
|
+
* key columns once into a pair of row-match indices — output row `r` takes
|
|
6
|
+
* left row `leftIdx[r]` and right row `rightIdx[r]`, with `-1` meaning "no
|
|
7
|
+
* row on that side" — then builds every output column from those indices:
|
|
8
|
+
*
|
|
9
|
+
* - **Pass-through.** When one side's index is the identity (each of its
|
|
10
|
+
* rows appears once, in order, with nothing interleaved), that side's
|
|
11
|
+
* value columns are adopted by reference — and, on the left, the key
|
|
12
|
+
* column too. This is always true of the primary in a `left` join and of
|
|
13
|
+
* the other side in a `right` join, and it is true of **both** sides when
|
|
14
|
+
* the two keys match one for one — the `joinMany` over a shared grid case.
|
|
15
|
+
* - **Gather.** Otherwise each column is one `sliceByIndices` over the match
|
|
16
|
+
* index; a `-1` slot comes out missing through the column's validity, the
|
|
17
|
+
* substrate's existing out-of-range gather contract.
|
|
18
|
+
*
|
|
19
|
+
* No `Event` is materialised on either side or in the output.
|
|
20
|
+
*
|
|
21
|
+
* **Semantics are the event walk's, exactly.** The key comparison is
|
|
22
|
+
* `compareEventKeys` / `Interval.compare` restated over the buffers —
|
|
23
|
+
* `begin`, then `end`, then (interval keys only) `compareIntervalValues` on
|
|
24
|
+
* the labels — and the walk pairs equal keys **one to one** in order: a key
|
|
25
|
+
* that repeats `a` times on the left and `b` times on the right yields
|
|
26
|
+
* `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a·b`. A matched
|
|
27
|
+
* row carries the **left** key.
|
|
28
|
+
*
|
|
29
|
+
* Complexity: O(N + M) for the walk, plus O(R) per gathered column (R output
|
|
30
|
+
* rows). Pass-through columns cost nothing.
|
|
31
|
+
*
|
|
32
|
+
* The caller has already checked that the key kinds agree and that no value
|
|
33
|
+
* column name appears on both sides; `outSchema` is the left key column
|
|
34
|
+
* followed by the left then the right value columns.
|
|
35
|
+
*/
|
|
36
|
+
export function joinOp(left, right, joinType, outSchema) {
|
|
37
|
+
const lk = left.keys;
|
|
38
|
+
const rk = right.keys;
|
|
39
|
+
const n = lk.length;
|
|
40
|
+
const m = rk.length;
|
|
41
|
+
const keepLeft = joinType === 'left' || joinType === 'outer';
|
|
42
|
+
const keepRight = joinType === 'right' || joinType === 'outer';
|
|
43
|
+
// Exact for left / right (every row of the kept side is emitted once), an
|
|
44
|
+
// upper bound for inner / outer.
|
|
45
|
+
const capacity = joinType === 'left'
|
|
46
|
+
? n
|
|
47
|
+
: joinType === 'right'
|
|
48
|
+
? m
|
|
49
|
+
: joinType === 'inner'
|
|
50
|
+
? Math.min(n, m)
|
|
51
|
+
: n + m;
|
|
52
|
+
const leftIdx = new Int32Array(capacity);
|
|
53
|
+
const rightIdx = new Int32Array(capacity);
|
|
54
|
+
const compare = keyComparator(lk, rk);
|
|
55
|
+
let i = 0;
|
|
56
|
+
let j = 0;
|
|
57
|
+
let len = 0;
|
|
58
|
+
let leftOnly = 0;
|
|
59
|
+
let rightOnly = 0;
|
|
60
|
+
while (i < n && j < m) {
|
|
61
|
+
const c = compare(i, j);
|
|
62
|
+
if (c === 0) {
|
|
63
|
+
leftIdx[len] = i;
|
|
64
|
+
rightIdx[len] = j;
|
|
65
|
+
len += 1;
|
|
66
|
+
i += 1;
|
|
67
|
+
j += 1;
|
|
68
|
+
}
|
|
69
|
+
else if (c < 0) {
|
|
70
|
+
if (keepLeft) {
|
|
71
|
+
leftIdx[len] = i;
|
|
72
|
+
rightIdx[len] = -1;
|
|
73
|
+
len += 1;
|
|
74
|
+
leftOnly += 1;
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (keepRight) {
|
|
80
|
+
leftIdx[len] = -1;
|
|
81
|
+
rightIdx[len] = j;
|
|
82
|
+
len += 1;
|
|
83
|
+
rightOnly += 1;
|
|
84
|
+
}
|
|
85
|
+
j += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (keepLeft) {
|
|
89
|
+
for (; i < n; i += 1) {
|
|
90
|
+
leftIdx[len] = i;
|
|
91
|
+
rightIdx[len] = -1;
|
|
92
|
+
len += 1;
|
|
93
|
+
leftOnly += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (keepRight) {
|
|
97
|
+
for (; j < m; j += 1) {
|
|
98
|
+
leftIdx[len] = -1;
|
|
99
|
+
rightIdx[len] = j;
|
|
100
|
+
len += 1;
|
|
101
|
+
rightOnly += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// A side's index is the identity iff all its rows were emitted and the
|
|
105
|
+
// other side contributed no one-sided rows between them (rows of one side
|
|
106
|
+
// are always emitted in ascending order).
|
|
107
|
+
const leftIdentity = len === n && rightOnly === 0;
|
|
108
|
+
const rightIdentity = len === m && leftOnly === 0;
|
|
109
|
+
const li = leftIdx.subarray(0, len);
|
|
110
|
+
const ri = rightIdx.subarray(0, len);
|
|
111
|
+
// A matched row carries the left key, so only the left key column is ever
|
|
112
|
+
// adopted. The right one is not a safe stand-in even when its index is the
|
|
113
|
+
// identity: equal keys need not be identical — interval labels compare with
|
|
114
|
+
// `localeCompare` (a precomposed and a decomposed é are equal), and
|
|
115
|
+
// timestamps with `-`, under which `0` and `-0` are equal.
|
|
116
|
+
const keys = leftIdentity ? lk : gatherKeys(lk, rk, li, ri);
|
|
117
|
+
const columns = new Map();
|
|
118
|
+
for (let c = 1; c < left.schema.length; c += 1) {
|
|
119
|
+
const name = left.schema[c].name;
|
|
120
|
+
const col = left.columns.get(name);
|
|
121
|
+
columns.set(name, leftIdentity ? col : col.sliceByIndices(li));
|
|
122
|
+
}
|
|
123
|
+
for (let c = 1; c < right.schema.length; c += 1) {
|
|
124
|
+
const name = right.schema[c].name;
|
|
125
|
+
const col = right.columns.get(name);
|
|
126
|
+
columns.set(name, rightIdentity ? col : col.sliceByIndices(ri));
|
|
127
|
+
}
|
|
128
|
+
return ColumnarStore.fromTrustedStore(outSchema, keys, columns);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The event walk's key order (`compareEventKeys`, plus `Interval.compare`'s
|
|
132
|
+
* label tiebreak) over the raw key buffers. Both columns are the same kind.
|
|
133
|
+
*/
|
|
134
|
+
function keyComparator(lk, rk) {
|
|
135
|
+
const lb = lk.begin;
|
|
136
|
+
const rb = rk.begin;
|
|
137
|
+
if (lk.kind === 'time' || lk.kind === 'value') {
|
|
138
|
+
return (i, j) => lb[i] - rb[j];
|
|
139
|
+
}
|
|
140
|
+
const le = lk.end;
|
|
141
|
+
const re = rk.end;
|
|
142
|
+
if (lk.kind === 'timeRange') {
|
|
143
|
+
return (i, j) => {
|
|
144
|
+
const d = lb[i] - rb[j];
|
|
145
|
+
return d !== 0 ? d : le[i] - re[j];
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const ll = lk.labels;
|
|
149
|
+
const rl = rk.labels;
|
|
150
|
+
return (i, j) => {
|
|
151
|
+
const d = lb[i] - rb[j];
|
|
152
|
+
if (d !== 0)
|
|
153
|
+
return d;
|
|
154
|
+
const e = le[i] - re[j];
|
|
155
|
+
if (e !== 0)
|
|
156
|
+
return e;
|
|
157
|
+
return compareIntervalValues(ll.read(i), rl.read(j));
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Builds the output key column row by row from whichever side has the row,
|
|
162
|
+
* preferring the left. Every `(begin, end, label)` triple is copied whole
|
|
163
|
+
* from one already-validated source row, so the per-row invariants (finite,
|
|
164
|
+
* `begin <= end`, defined label) hold by construction and the trusted
|
|
165
|
+
* factories skip re-checking them.
|
|
166
|
+
*/
|
|
167
|
+
function gatherKeys(lk, rk, li, ri) {
|
|
168
|
+
const len = li.length;
|
|
169
|
+
const lb = lk.begin;
|
|
170
|
+
const rb = rk.begin;
|
|
171
|
+
const begin = new Float64Array(len);
|
|
172
|
+
if (lk.kind === 'time' || lk.kind === 'value') {
|
|
173
|
+
for (let r = 0; r < len; r += 1) {
|
|
174
|
+
const a = li[r];
|
|
175
|
+
begin[r] = a >= 0 ? lb[a] : rb[ri[r]];
|
|
176
|
+
}
|
|
177
|
+
return lk.kind === 'time'
|
|
178
|
+
? TimeKeyColumn.fromValidatedSubarray(begin, len)
|
|
179
|
+
: new ValueKeyColumn(begin, len);
|
|
180
|
+
}
|
|
181
|
+
const le = lk.end;
|
|
182
|
+
const re = rk.end;
|
|
183
|
+
const end = new Float64Array(len);
|
|
184
|
+
for (let r = 0; r < len; r += 1) {
|
|
185
|
+
const a = li[r];
|
|
186
|
+
if (a >= 0) {
|
|
187
|
+
begin[r] = lb[a];
|
|
188
|
+
end[r] = le[a];
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const b = ri[r];
|
|
192
|
+
begin[r] = rb[b];
|
|
193
|
+
end[r] = re[b];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (lk.kind === 'timeRange') {
|
|
197
|
+
return TimeRangeKeyColumn.fromValidatedSubarray(begin, end, len);
|
|
198
|
+
}
|
|
199
|
+
const { labels, labelKind } = gatherLabels(lk, rk, li, ri);
|
|
200
|
+
return IntervalKeyColumn.fromValidatedSubarray(begin, end, labels, labelKind, len);
|
|
201
|
+
}
|
|
202
|
+
function gatherLabels(lk, rk, li, ri) {
|
|
203
|
+
const len = li.length;
|
|
204
|
+
let fromLeft = 0;
|
|
205
|
+
for (let r = 0; r < len; r += 1)
|
|
206
|
+
if (li[r] >= 0)
|
|
207
|
+
fromLeft += 1;
|
|
208
|
+
if (fromLeft === len) {
|
|
209
|
+
return {
|
|
210
|
+
labels: lk.labels.sliceByIndices(li),
|
|
211
|
+
labelKind: lk.labelKind,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (fromLeft === 0) {
|
|
215
|
+
return {
|
|
216
|
+
labels: rk.labels.sliceByIndices(ri),
|
|
217
|
+
labelKind: rk.labelKind,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Mixed-type labels never match (`compareIntervalValues` orders numbers
|
|
221
|
+
// before strings), so this is an outer join keeping one-sided rows from
|
|
222
|
+
// both. The event path rejected the same output when re-columnarising it.
|
|
223
|
+
if (lk.labelKind !== rk.labelKind) {
|
|
224
|
+
throw new RangeError(`join: cannot combine interval keys with ${lk.labelKind} labels and interval keys with ${rk.labelKind} labels — an interval-keyed series must use one label type throughout`);
|
|
225
|
+
}
|
|
226
|
+
if (lk.labelKind === 'number') {
|
|
227
|
+
const lv = lk.labels._values;
|
|
228
|
+
const rv = rk.labels._values;
|
|
229
|
+
const out = new Float64Array(len);
|
|
230
|
+
for (let r = 0; r < len; r += 1) {
|
|
231
|
+
const a = li[r];
|
|
232
|
+
out[r] = a >= 0 ? lv[a] : rv[ri[r]];
|
|
233
|
+
}
|
|
234
|
+
// Numeric interval labels are validated finite at construction.
|
|
235
|
+
return {
|
|
236
|
+
labels: new Float64Column(out, len, undefined, true),
|
|
237
|
+
labelKind: lk.labelKind,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const ll = lk.labels;
|
|
241
|
+
const rl = rk.labels;
|
|
242
|
+
const out = new Array(len);
|
|
243
|
+
for (let r = 0; r < len; r += 1) {
|
|
244
|
+
const a = li[r];
|
|
245
|
+
out[r] = (a >= 0 ? ll.read(a) : rl.read(ri[r]));
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
labels: stringColumnFromArray(out, { forceDict: true }),
|
|
249
|
+
labelKind: lk.labelKind,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=join.js.map
|
|
@@ -619,6 +619,19 @@ export declare class TimeSeries<S extends SeriesSchema> {
|
|
|
619
619
|
* Value columns from both series are included in the result and are optional because joined rows
|
|
620
620
|
* may have missing values on either side. If both series use the same payload column name,
|
|
621
621
|
* you can either rename one side before joining or use `{ onConflict: "prefix", prefixes: [...] }`.
|
|
622
|
+
*
|
|
623
|
+
* Keys pair **one to one** in order: a key that appears `a` times on the left and `b` times on
|
|
624
|
+
* the right gives `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a × b`. A matched
|
|
625
|
+
* row carries the left key.
|
|
626
|
+
*
|
|
627
|
+
* **Cost.** Column-native: one walk over the two key columns, then each output column is either
|
|
628
|
+
* the source column itself (no copy) or one gather. A side's columns pass through untouched
|
|
629
|
+
* whenever every one of its rows lands in the output once, in order, with nothing interleaved —
|
|
630
|
+
* always the left side of a `"left"` join and the right side of a `"right"` join, and both sides
|
|
631
|
+
* when the keys match one for one (`joinMany` over a shared grid). So the cost of a left join
|
|
632
|
+
* scales with the **other** side's width; to bring across only the columns you read, narrow it
|
|
633
|
+
* first — `select` and `rename` are themselves zero-copy:
|
|
634
|
+
* `bars.join(spy.select("close").rename({ close: "spy" }), { type: "left" })`.
|
|
622
635
|
*/
|
|
623
636
|
join<Other extends SeriesSchema>(other: TimeSeries<Other>, options?: ErrorJoinOptions): TimeSeries<JoinSchema<S, Other>>;
|
|
624
637
|
join<Other extends SeriesSchema, const Prefixes extends readonly [string, string]>(other: TimeSeries<Other>, options: PrefixJoinOptions<Prefixes>): TimeSeries<PrefixedJoinSchema<S, Other, Prefixes>>;
|
|
@@ -12,6 +12,7 @@ import { diffRateOp } from './operators/diff-rate.js';
|
|
|
12
12
|
import { fillOp } from './operators/fill.js';
|
|
13
13
|
import { mapOp } from './operators/map.js';
|
|
14
14
|
import { shiftOp } from './operators/shift.js';
|
|
15
|
+
import { joinOp } from './operators/join.js';
|
|
15
16
|
import { collapseOp } from './operators/collapse.js';
|
|
16
17
|
import { assertColumnValuesMatchKind, columnFromValuesByKind, } from './operators/column-builders.js';
|
|
17
18
|
import { computeByColumn } from './by-column.js';
|
|
@@ -1499,46 +1500,10 @@ export class TimeSeries {
|
|
|
1499
1500
|
.slice(1)
|
|
1500
1501
|
.map((column) => ({ ...column, required: false })),
|
|
1501
1502
|
]);
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const leftEvent = left.events[leftIndex];
|
|
1507
|
-
const rightEvent = right.events[rightIndex];
|
|
1508
|
-
if (leftEvent && !rightEvent) {
|
|
1509
|
-
if (joinType === 'left' || joinType === 'outer') {
|
|
1510
|
-
joinedEvents.push(leftEvent.merge({}));
|
|
1511
|
-
}
|
|
1512
|
-
leftIndex += 1;
|
|
1513
|
-
continue;
|
|
1514
|
-
}
|
|
1515
|
-
if (rightEvent && !leftEvent) {
|
|
1516
|
-
if (joinType === 'right' || joinType === 'outer') {
|
|
1517
|
-
joinedEvents.push(rightEvent.merge({}));
|
|
1518
|
-
}
|
|
1519
|
-
rightIndex += 1;
|
|
1520
|
-
continue;
|
|
1521
|
-
}
|
|
1522
|
-
const comparison = leftEvent.key().compare(rightEvent.key());
|
|
1523
|
-
if (comparison === 0) {
|
|
1524
|
-
joinedEvents.push(leftEvent.merge(rightEvent.data()));
|
|
1525
|
-
leftIndex += 1;
|
|
1526
|
-
rightIndex += 1;
|
|
1527
|
-
}
|
|
1528
|
-
else if (comparison < 0) {
|
|
1529
|
-
if (joinType === 'left' || joinType === 'outer') {
|
|
1530
|
-
joinedEvents.push(leftEvent.merge({}));
|
|
1531
|
-
}
|
|
1532
|
-
leftIndex += 1;
|
|
1533
|
-
}
|
|
1534
|
-
else {
|
|
1535
|
-
if (joinType === 'right' || joinType === 'outer') {
|
|
1536
|
-
joinedEvents.push(rightEvent.merge({}));
|
|
1537
|
-
}
|
|
1538
|
-
rightIndex += 1;
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
return TimeSeries.#fromTrustedEvents(left.name, resultSchema, joinedEvents);
|
|
1503
|
+
// Column-native: merge-walk the key buffers, then pass each column
|
|
1504
|
+
// through or gather it. See `joinOp`.
|
|
1505
|
+
const store = joinOp(left.#store.store, right.#store.store, joinType, resultSchema);
|
|
1506
|
+
return TimeSeries.#fromTrustedStore(left.name, resultSchema, store);
|
|
1542
1507
|
}
|
|
1543
1508
|
/**
|
|
1544
1509
|
* Example: `series.align(Sequence.every("1m"))`.
|
|
@@ -357,12 +357,12 @@ function validateCachedEvent(rowIndex, cachedEvent, store, schemaValueNames) {
|
|
|
357
357
|
// kind-aware equality.
|
|
358
358
|
// (b) when the field is **absent** from `cachedData`, the
|
|
359
359
|
// column at that row must read as `undefined` — i.e. the
|
|
360
|
-
// event genuinely doesn't carry that column. This
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
360
|
+
// event genuinely doesn't carry that column. This shape was
|
|
361
|
+
// admitted for the event-walking outer `join`, whose unmatched
|
|
362
|
+
// rows omitted the other side's columns. `join` is column-native
|
|
363
|
+
// now ([PND-JOINCOL]) and no longer produces it, but a caller-
|
|
364
|
+
// supplied cache can, so the relaxation stands. Pre-2a
|
|
365
|
+
// TimeSeries treated this as a row-API concern; the
|
|
366
366
|
// relaxation here keeps the original misalignment-detection
|
|
367
367
|
// property — a cached event whose data is missing a field
|
|
368
368
|
// for which the column DOES read a defined value still
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pond-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Typed time series for TypeScript: schema-driven TimeSeries + streaming LiveSeries with aggregate, rolling, align, fill, partitionBy and typed columns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-series",
|