@pond-ts/charts 0.42.0 → 0.43.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/CHANGELOG.md +83 -1
- package/dist/BarChart.d.ts +24 -2
- package/dist/BarChart.js +82 -22
- package/dist/CategoryAxis.d.ts +16 -0
- package/dist/CategoryAxis.js +19 -0
- package/dist/ChartContainer.d.ts +48 -1
- package/dist/ChartContainer.js +78 -3
- package/dist/Layers.js +73 -5
- package/dist/XAxis.js +41 -2
- package/dist/annotations.d.ts +38 -1
- package/dist/annotations.js +68 -25
- package/dist/bandScale.d.ts +57 -0
- package/dist/bandScale.js +67 -0
- package/dist/bars.d.ts +23 -6
- package/dist/bars.js +47 -15
- package/dist/context.d.ts +69 -13
- package/dist/data.d.ts +72 -0
- package/dist/data.js +67 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +8 -1
- package/dist/tracker.d.ts +37 -0
- package/dist/tracker.js +77 -6
- package/package.json +3 -3
package/dist/Layers.js
CHANGED
|
@@ -2,7 +2,8 @@ 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 {
|
|
5
|
+
import { TimeRange } from 'pond-ts';
|
|
6
|
+
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
6
7
|
import { resolveSelection } from './select.js';
|
|
7
8
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
8
9
|
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
@@ -69,7 +70,9 @@ export function Layers({ children }) {
|
|
|
69
70
|
// Explicit `<YAxis ticks>` drive the gridlines too, so they align with the
|
|
70
71
|
// axis labels; otherwise d3 auto-picks (the default).
|
|
71
72
|
const explicitY = tickValues.get(defaultAxisId);
|
|
72
|
-
|
|
73
|
+
// A category axis draws no vertical gridlines — a line through each bar
|
|
74
|
+
// centre reads as noise; the bars are the structure.
|
|
75
|
+
const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
|
|
73
76
|
const xTicks = xTickVals.map((d) => xScale(+d));
|
|
74
77
|
const yTicks = gridY
|
|
75
78
|
? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
|
|
@@ -257,7 +260,29 @@ export function Layers({ children }) {
|
|
|
257
260
|
}
|
|
258
261
|
return;
|
|
259
262
|
}
|
|
260
|
-
|
|
263
|
+
// Region-cursor drag-select (opt-in via `onRegionSelect`, time axis only):
|
|
264
|
+
// anchor the selection at the press; the band then extends as the pointer
|
|
265
|
+
// moves (bucket by bucket with a sequence, freeform without), and release
|
|
266
|
+
// commits the range. A `regionSelectModifier` (only while `panZoom` is on)
|
|
267
|
+
// gates it behind the key so plain drag can still pan; otherwise it preempts
|
|
268
|
+
// pan (returns before the pan is armed below).
|
|
269
|
+
if (c.cursor === 'region' && c.onRegionSelect && c.xKind === 'time') {
|
|
270
|
+
const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
|
|
271
|
+
if (!needsShift || e.shiftKey) {
|
|
272
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
273
|
+
c.setRegionAnchor(+c.xScale.invert(px));
|
|
274
|
+
c.setHoverX(px);
|
|
275
|
+
try {
|
|
276
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Modifier required but not held → fall through to pan.
|
|
284
|
+
}
|
|
285
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
261
286
|
return;
|
|
262
287
|
const r = c.timeRange;
|
|
263
288
|
// Arm a potential pan: record the anchor, but DON'T capture the pointer or
|
|
@@ -287,6 +312,13 @@ export function Layers({ children }) {
|
|
|
287
312
|
c.setHoverX(px); // share the preview x so other rows draw a guide there
|
|
288
313
|
return;
|
|
289
314
|
}
|
|
315
|
+
// Region drag in progress: just track the pointer x (the band spans from the
|
|
316
|
+
// anchor bucket to here); no pan, no hover hit-test.
|
|
317
|
+
if (c.regionAnchor !== null) {
|
|
318
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
319
|
+
c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
290
322
|
// A pan is only live while a button is held. A move with no buttons means
|
|
291
323
|
// the press already ended without us seeing the pointerup — which the
|
|
292
324
|
// deferred-capture path allows: an uncommitted (sub-slop) potential-pan
|
|
@@ -372,6 +404,22 @@ export function Layers({ children }) {
|
|
|
372
404
|
}, []);
|
|
373
405
|
const handlePointerUp = useCallback((e) => {
|
|
374
406
|
const c = containerRef.current;
|
|
407
|
+
// End a region drag: commit the anchor→pointer span as a one-shot range,
|
|
408
|
+
// then clear the anchor (the cursor reverts to the single-bucket highlight).
|
|
409
|
+
if (c.regionAnchor !== null) {
|
|
410
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
411
|
+
const span = regionSpan(c.cursorBuckets ?? [], c.regionAnchor, +c.xScale.invert(px));
|
|
412
|
+
c.setRegionAnchor(null);
|
|
413
|
+
try {
|
|
414
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
if (span)
|
|
420
|
+
c.onRegionSelect?.(new TimeRange({ start: span.start, end: span.end }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
375
423
|
if (c.creating !== null) {
|
|
376
424
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
377
425
|
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
@@ -439,6 +487,10 @@ export function Layers({ children }) {
|
|
|
439
487
|
c.setHoverY(null, null);
|
|
440
488
|
return;
|
|
441
489
|
}
|
|
490
|
+
// Cancel a region-drag on leave (no commit) — a safety net for the rare case
|
|
491
|
+
// where the pointer capture didn't take, so the anchor can't get stuck.
|
|
492
|
+
if (c.regionAnchor !== null)
|
|
493
|
+
c.setRegionAnchor(null);
|
|
442
494
|
c.setHoverX(null);
|
|
443
495
|
c.setHoverY(null, null);
|
|
444
496
|
c.setHovered(null);
|
|
@@ -478,7 +530,7 @@ export function Layers({ children }) {
|
|
|
478
530
|
return;
|
|
479
531
|
const onWheel = (e) => {
|
|
480
532
|
const c = containerRef.current;
|
|
481
|
-
if (!c.panZoom)
|
|
533
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
482
534
|
return;
|
|
483
535
|
e.preventDefault();
|
|
484
536
|
const rect = el.getBoundingClientRect();
|
|
@@ -571,6 +623,22 @@ export function Layers({ children }) {
|
|
|
571
623
|
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
572
624
|
};
|
|
573
625
|
})();
|
|
626
|
+
// `region` cursor (time axis only): shade the span under the pointer. With a
|
|
627
|
+
// `cursorSequence` the band snaps to the bucket (and extends bucket by bucket
|
|
628
|
+
// under a drag); with none it's the **freeform** case — a bare hover draws a
|
|
629
|
+
// plain line (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges
|
|
630
|
+
// map through `xScale`, so on a trading-time axis the band crops to live time.
|
|
631
|
+
const regionActive = parts.band && container.xKind === 'time';
|
|
632
|
+
const band = regionActive && cursorTime !== null
|
|
633
|
+
? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
|
|
634
|
+
: null;
|
|
635
|
+
// Degenerate region cursor (no sequence, not mid-drag): a plain vertical line.
|
|
636
|
+
const regionLine = regionActive &&
|
|
637
|
+
container.cursorBuckets === undefined &&
|
|
638
|
+
container.regionAnchor === null &&
|
|
639
|
+
cursorX !== null &&
|
|
640
|
+
cursorX >= 0 &&
|
|
641
|
+
cursorX <= plotWidth;
|
|
574
642
|
// Cross-row guide lines: the x-positions of annotations on the OTHER rows
|
|
575
643
|
// (markers + region edges), so a mark on one row reads against this row's data +
|
|
576
644
|
// the shared x axis. A mark's own row skips itself; baselines cast no vertical
|
|
@@ -638,7 +706,7 @@ export function Layers({ children }) {
|
|
|
638
706
|
top: 0,
|
|
639
707
|
left: 0,
|
|
640
708
|
pointerEvents: 'none',
|
|
641
|
-
}, children: [parts.line &&
|
|
709
|
+
}, children: [band !== null && (_jsx("rect", { x: band.x0, y: 0, width: band.x1 - band.x0, height: row.height, fill: cursorColor, opacity: 0.12 })), regionLine && cursorX !== null && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.line &&
|
|
642
710
|
cursorX !== null &&
|
|
643
711
|
cursorX >= 0 &&
|
|
644
712
|
cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
|
package/dist/XAxis.js
CHANGED
|
@@ -8,6 +8,36 @@ const TICK_STRIP = 22;
|
|
|
8
8
|
/** Extra height reserved for an axis `label` line. */
|
|
9
9
|
const LABEL_STRIP = 16;
|
|
10
10
|
const TICK_COUNT = 5;
|
|
11
|
+
/**
|
|
12
|
+
* Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
|
|
13
|
+
* every `stride`-th label (so a kept label has room), and ellipsize one that still
|
|
14
|
+
* overruns its space. `stride` grows with the longest label vs the per-category
|
|
15
|
+
* slot width, so a few short categories keep every full label and many long ones
|
|
16
|
+
* decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
|
|
17
|
+
* for placement; the exact metric is the browser's. Rotation is a later option.
|
|
18
|
+
*/
|
|
19
|
+
function thinCategoryLabels(ticks, plotWidth, fontSize) {
|
|
20
|
+
const n = ticks.length;
|
|
21
|
+
const slot = plotWidth / n; // per-category width in px
|
|
22
|
+
// Before first layout `plotWidth` is 0 → `slot` is 0 and the stride/room math
|
|
23
|
+
// below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
|
|
24
|
+
// the ticks through untouched until a real width arrives.
|
|
25
|
+
if (!(slot > 0))
|
|
26
|
+
return [...ticks];
|
|
27
|
+
const charW = fontSize * 0.62; // ~average glyph advance
|
|
28
|
+
const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
|
|
29
|
+
const stride = Math.max(1, Math.ceil((longest * charW) / slot));
|
|
30
|
+
const room = Math.max(1, Math.floor((slot * stride) / charW));
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < n; i += stride) {
|
|
33
|
+
const s = ticks[i].label;
|
|
34
|
+
out.push({
|
|
35
|
+
x: ticks[i].x,
|
|
36
|
+
label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
11
41
|
/**
|
|
12
42
|
* The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
|
|
13
43
|
* child of {@link ChartContainer}, rendered as DOM chrome (crisp text,
|
|
@@ -39,7 +69,11 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
39
69
|
// (a time specifier through the time scale, a number specifier through the
|
|
40
70
|
// value scale); otherwise the container's shared formatter — the one the
|
|
41
71
|
// cursor readout uses, so a tick and the cursor read identically.
|
|
42
|
-
const fmt =
|
|
72
|
+
const fmt =
|
|
73
|
+
// A category axis labels by name (the container's `formatTime` = the band
|
|
74
|
+
// scale's label lookup); a d3 number/time `format` can't name a category, so
|
|
75
|
+
// it's ignored here (customize the labels in the `categories` data instead).
|
|
76
|
+
format === undefined || xKind === 'category'
|
|
43
77
|
? formatTime
|
|
44
78
|
: xKind === 'time'
|
|
45
79
|
? resolveTimeFormat(xScale, TICK_COUNT, format)
|
|
@@ -90,12 +124,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
90
124
|
markerLanes.set(t.id, lane);
|
|
91
125
|
}
|
|
92
126
|
const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
|
|
93
|
-
const
|
|
127
|
+
const rawTicks = customTicks
|
|
94
128
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
95
129
|
: xScale.ticks(TICK_COUNT).map((d) => ({
|
|
96
130
|
x: xScale(d),
|
|
97
131
|
label: fmt(+d),
|
|
98
132
|
}));
|
|
133
|
+
// A category axis ticks once per category; thin + truncate its labels when they
|
|
134
|
+
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
135
|
+
const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
|
|
136
|
+
? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
|
|
137
|
+
: rawTicks;
|
|
99
138
|
const onTop = side === 'top';
|
|
100
139
|
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
101
140
|
// they line up with their tick-label neighbours (matches `labelOffset` below).
|
package/dist/annotations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
1
|
+
import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from './context.js';
|
|
2
2
|
/**
|
|
3
3
|
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
4
4
|
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
@@ -16,6 +16,21 @@ import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
|
16
16
|
* label) so the static labels hold their lanes as it crosses them.
|
|
17
17
|
*/
|
|
18
18
|
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
|
|
19
|
+
/**
|
|
20
|
+
* Snap a dragged plot-pixel `px` to the nearest **guideline** within
|
|
21
|
+
* {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
|
|
22
|
+
* boundary** (a session collapse point). Returns the **axis** value to snap to,
|
|
23
|
+
* or `null` if none is near (the caller keeps the raw position). Excludes the
|
|
24
|
+
* dragging mark's own `key`, and reads the same registry the guides draw from,
|
|
25
|
+
* so a drag visibly clicks onto the lines you can see.
|
|
26
|
+
*
|
|
27
|
+
* At a disjoint boundary the close and the next open share a pixel, so the value
|
|
28
|
+
* depends on which side of it the pointer is on (see below). Nearest-pixel wins
|
|
29
|
+
* across both kinds of target, so an annotation sitting *exactly* on a boundary
|
|
30
|
+
* open ties and — processed first — takes it (its own guideline), which is the
|
|
31
|
+
* same instant the right-side heuristic would pick anyway.
|
|
32
|
+
*/
|
|
33
|
+
export declare function snapToGuides(container: ContainerFrame, selfKey: symbol, px: number): number | null;
|
|
19
34
|
/**
|
|
20
35
|
* Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
|
|
21
36
|
* the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
|
|
@@ -28,6 +43,27 @@ export declare function orderRegion(v: number, pivot: number): {
|
|
|
28
43
|
from: number;
|
|
29
44
|
to: number;
|
|
30
45
|
};
|
|
46
|
+
/** The slice of a scale a rigid pixel-move needs: value → pixel and back.
|
|
47
|
+
* `invert` may return a `Date` (a d3 `scaleTime`) — the move coerces with `+`. */
|
|
48
|
+
interface InvertibleScale {
|
|
49
|
+
(value: number): number;
|
|
50
|
+
invert(pixel: number): number | Date;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
|
|
54
|
+
* the box moves rigidly in *pixel* space — each edge's pixel position shifts by
|
|
55
|
+
* the same `dpx`, then inverts back to an axis value.
|
|
56
|
+
*
|
|
57
|
+
* This is the move that stays correct on a **discontinuous** (trading-time) axis:
|
|
58
|
+
* a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
|
|
59
|
+
* when they sit in different gap-contexts, distorting the box as it crosses a
|
|
60
|
+
* collapsed gap. On a continuous (affine) scale it is identical to the value-delta
|
|
61
|
+
* move, so this is a no-op there.
|
|
62
|
+
*/
|
|
63
|
+
export declare function moveRegionByPixels(scale: InvertibleScale, from: number, to: number, dpx: number): {
|
|
64
|
+
from: number;
|
|
65
|
+
to: number;
|
|
66
|
+
};
|
|
31
67
|
export interface MarkerProps {
|
|
32
68
|
/** x position in axis units — epoch ms on a time axis, the value on a value
|
|
33
69
|
* axis. (The generalisation of the mockup's "time line": a mark at an x, time
|
|
@@ -157,4 +193,5 @@ export interface RegionProps {
|
|
|
157
193
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
158
194
|
* flies as a flag off the left edge. */
|
|
159
195
|
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
196
|
+
export {};
|
|
160
197
|
//# sourceMappingURL=annotations.d.ts.map
|
package/dist/annotations.js
CHANGED
|
@@ -257,13 +257,20 @@ function useAnnotationHover(container, id, hovered) {
|
|
|
257
257
|
/** Pixel radius within which a drag snaps to a guideline (another mark's x). */
|
|
258
258
|
const SNAP_PX = 6;
|
|
259
259
|
/**
|
|
260
|
-
* Snap a dragged plot-pixel `px` to the nearest **guideline**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
260
|
+
* Snap a dragged plot-pixel `px` to the nearest **guideline** within
|
|
261
|
+
* {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
|
|
262
|
+
* boundary** (a session collapse point). Returns the **axis** value to snap to,
|
|
263
|
+
* or `null` if none is near (the caller keeps the raw position). Excludes the
|
|
264
|
+
* dragging mark's own `key`, and reads the same registry the guides draw from,
|
|
265
|
+
* so a drag visibly clicks onto the lines you can see.
|
|
266
|
+
*
|
|
267
|
+
* At a disjoint boundary the close and the next open share a pixel, so the value
|
|
268
|
+
* depends on which side of it the pointer is on (see below). Nearest-pixel wins
|
|
269
|
+
* across both kinds of target, so an annotation sitting *exactly* on a boundary
|
|
270
|
+
* open ties and — processed first — takes it (its own guideline), which is the
|
|
271
|
+
* same instant the right-side heuristic would pick anyway.
|
|
265
272
|
*/
|
|
266
|
-
function snapToGuides(container, selfKey, px) {
|
|
273
|
+
export function snapToGuides(container, selfKey, px) {
|
|
267
274
|
// The container's snap toggle gates guideline snapping — off ⇒ the drag keeps
|
|
268
275
|
// its raw position (no clicking onto neighbours).
|
|
269
276
|
if (!container.snap)
|
|
@@ -281,6 +288,23 @@ function snapToGuides(container, selfKey, px) {
|
|
|
281
288
|
}
|
|
282
289
|
}
|
|
283
290
|
}
|
|
291
|
+
// Disjoint boundaries: on a trading-time axis a session close and the next
|
|
292
|
+
// open collapse to the **same pixel**, so a boundary is one snap target with
|
|
293
|
+
// two possible instants. Snap to the one on the side of the boundary the
|
|
294
|
+
// pointer is on — left of it → the pre-gap edge (the previous session's
|
|
295
|
+
// *close*, `clampDown` out of the gap); at/right of it → the post-gap *open*.
|
|
296
|
+
const disc = container.discontinuities;
|
|
297
|
+
if (disc?.boundaries) {
|
|
298
|
+
const [d0, d1] = container.timeRange;
|
|
299
|
+
for (const open of disc.boundaries(d0, d1)) {
|
|
300
|
+
const bpx = container.xScale(open);
|
|
301
|
+
const d = Math.abs(bpx - px);
|
|
302
|
+
if (d < bestDist) {
|
|
303
|
+
bestDist = d;
|
|
304
|
+
best = px < bpx ? disc.clampDown(open - 1) : open;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
284
308
|
return best;
|
|
285
309
|
}
|
|
286
310
|
/**
|
|
@@ -294,6 +318,23 @@ function snapToGuides(container, selfKey, px) {
|
|
|
294
318
|
export function orderRegion(v, pivot) {
|
|
295
319
|
return v <= pivot ? { from: v, to: pivot } : { from: pivot, to: v };
|
|
296
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
|
|
323
|
+
* the box moves rigidly in *pixel* space — each edge's pixel position shifts by
|
|
324
|
+
* the same `dpx`, then inverts back to an axis value.
|
|
325
|
+
*
|
|
326
|
+
* This is the move that stays correct on a **discontinuous** (trading-time) axis:
|
|
327
|
+
* a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
|
|
328
|
+
* when they sit in different gap-contexts, distorting the box as it crosses a
|
|
329
|
+
* collapsed gap. On a continuous (affine) scale it is identical to the value-delta
|
|
330
|
+
* move, so this is a no-op there.
|
|
331
|
+
*/
|
|
332
|
+
export function moveRegionByPixels(scale, from, to, dpx) {
|
|
333
|
+
return {
|
|
334
|
+
from: +scale.invert(scale(from) + dpx),
|
|
335
|
+
to: +scale.invert(scale(to) + dpx),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
297
338
|
/** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
|
|
298
339
|
* filled, no outline) with text in the annotation register. */
|
|
299
340
|
function Chip({ theme, color, style, children, }) {
|
|
@@ -550,25 +591,27 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
550
591
|
const s = dragRef.current;
|
|
551
592
|
if (s === null)
|
|
552
593
|
return;
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
594
|
+
// Rigid move by the TOTAL pointer *pixel* delta from the press
|
|
595
|
+
// origin — each edge shifts the same pixels through the scale, so
|
|
596
|
+
// the box holds its shape even across a collapsed gap (a shared
|
|
597
|
+
// value-delta would drift the edges apart there).
|
|
598
|
+
const moved = moveRegionByPixels(container.xScale, s.from, s.to, px - s.startPx);
|
|
599
|
+
// Snap either edge to a guideline, shifting BOTH by the same pixel
|
|
600
|
+
// correction so the box keeps its width; snap-independent, so a
|
|
601
|
+
// drag past SNAP_PX releases cleanly.
|
|
602
|
+
const fpx = container.xScale(moved.from);
|
|
603
|
+
const tpx = container.xScale(moved.to);
|
|
604
|
+
const sf = snapToGuides(container, selfKey, fpx);
|
|
605
|
+
const st = snapToGuides(container, selfKey, tpx);
|
|
606
|
+
const d = sf !== null
|
|
607
|
+
? container.xScale(sf) - fpx
|
|
608
|
+
: st !== null
|
|
609
|
+
? container.xScale(st) - tpx
|
|
610
|
+
: 0;
|
|
611
|
+
onChange?.({
|
|
612
|
+
from: +container.xScale.invert(fpx + d),
|
|
613
|
+
to: +container.xScale.invert(tpx + d),
|
|
614
|
+
});
|
|
572
615
|
} }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
|
|
573
616
|
edgeRef.current = to; // the fixed pivot = the far edge
|
|
574
617
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A d3-scale-shaped **ordinal band scale** for a categorical x-axis — the
|
|
3
|
+
* transpose view's "columns on x" (categorical-axis RFC, Phase 1). It exposes the
|
|
4
|
+
* slice of the d3 scale surface `@pond-ts/charts` actually uses, so it drops in
|
|
5
|
+
* wherever the container's `xScale` goes (the same trick {@link TradingTimeScale}
|
|
6
|
+
* uses for a discontinuous time axis).
|
|
7
|
+
*
|
|
8
|
+
* **Numeric slot-index domain (the load-bearing choice).** The domain is
|
|
9
|
+
* `[0, n]` — one unit slot per category, slot `i` occupying `[i, i+1]` — *not* a
|
|
10
|
+
* `string[]`. So the pixel **mapping stays linear** and the container's numeric
|
|
11
|
+
* domain / auto-fit / `range` pipeline is untouched; a bar layer draws each
|
|
12
|
+
* category with the ordinary `barSpanPx(i, i+1, …)`. The category-ness lives in
|
|
13
|
+
* three methods only:
|
|
14
|
+
*
|
|
15
|
+
* - {@link ScaleBand.ticks} → the band **centres** (`i + 0.5`), one per category;
|
|
16
|
+
* - {@link ScaleBand.invert} → snaps a pixel to the nearest slot's centre (the
|
|
17
|
+
* categorical crosshair UX, and it keeps the `+xScale.invert` call sites happy);
|
|
18
|
+
* - {@link ScaleBand.label} → the category name at a slot (the axis formatter).
|
|
19
|
+
*
|
|
20
|
+
* The category labels are carried alongside for {@link ScaleBand.label}; the
|
|
21
|
+
* numeric domain is authoritative for geometry.
|
|
22
|
+
*/
|
|
23
|
+
export interface ScaleBand {
|
|
24
|
+
/** Slot value (`i` = left edge, `i + 0.5` = centre) → pixel. Linear. */
|
|
25
|
+
(value: number): number;
|
|
26
|
+
/** Pixel → the nearest slot's **centre** value (`i + 0.5`), clamped to a real slot. */
|
|
27
|
+
invert(pixel: number): number;
|
|
28
|
+
/** One tick per category, at its band **centre** (`i + 0.5`). */
|
|
29
|
+
ticks(count?: number): number[];
|
|
30
|
+
/**
|
|
31
|
+
* A formatter mapping a slot value → the category name (the numeric `specifier`
|
|
32
|
+
* is ignored — a category axis labels by name, not a number format). Present so
|
|
33
|
+
* the scale is a safe drop-in wherever the container resolves a `tickFormat`.
|
|
34
|
+
*/
|
|
35
|
+
tickFormat(count?: number, specifier?: string): (value: number) => string;
|
|
36
|
+
/** One slot's width in pixels (`|range| / slots`). The bar's `gap` insets within it. */
|
|
37
|
+
bandwidth(): number;
|
|
38
|
+
/** Slot pitch in pixels — same as {@link bandwidth} (padding is the bar's `gap`). */
|
|
39
|
+
step(): number;
|
|
40
|
+
/** The category name at slot value `v` (`categories[floor(v)]`), or `''`. */
|
|
41
|
+
label(value: number): string;
|
|
42
|
+
domain(): [number, number];
|
|
43
|
+
domain(next: readonly [number, number]): ScaleBand;
|
|
44
|
+
range(): [number, number];
|
|
45
|
+
range(next: readonly [number, number]): ScaleBand;
|
|
46
|
+
copy(): ScaleBand;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a {@link ScaleBand} over an ordered list of category names. Configure like
|
|
50
|
+
* a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
|
|
51
|
+
* container sets `domain([0, n])` from the layer's slot extent and `range([0,
|
|
52
|
+
* plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
|
|
53
|
+
* so the two must agree on count (`categories.length === n`), which they do when
|
|
54
|
+
* both come from the same layer's `xCategories()` / slot extent.
|
|
55
|
+
*/
|
|
56
|
+
export declare function scaleBand(categories: readonly string[]): ScaleBand;
|
|
57
|
+
//# sourceMappingURL=bandScale.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { scaleLinear } from 'd3-scale';
|
|
2
|
+
/**
|
|
3
|
+
* Build a {@link ScaleBand} over an ordered list of category names. Configure like
|
|
4
|
+
* a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
|
|
5
|
+
* container sets `domain([0, n])` from the layer's slot extent and `range([0,
|
|
6
|
+
* plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
|
|
7
|
+
* so the two must agree on count (`categories.length === n`), which they do when
|
|
8
|
+
* both come from the same layer's `xCategories()` / slot extent.
|
|
9
|
+
*/
|
|
10
|
+
export function scaleBand(categories) {
|
|
11
|
+
let domain = [0, Math.max(1, categories.length)];
|
|
12
|
+
let range = [0, 1];
|
|
13
|
+
const lin = scaleLinear();
|
|
14
|
+
const sync = () => lin.domain(domain).range(range);
|
|
15
|
+
/** Slot count = the domain width (the container sets `[0, n]`). */
|
|
16
|
+
const slots = () => Math.max(0, Math.round(domain[1] - domain[0]));
|
|
17
|
+
const scale = ((value) => {
|
|
18
|
+
sync();
|
|
19
|
+
return lin(value);
|
|
20
|
+
});
|
|
21
|
+
scale.invert = (pixel) => {
|
|
22
|
+
sync();
|
|
23
|
+
const v = lin.invert(pixel);
|
|
24
|
+
const n = slots();
|
|
25
|
+
if (n === 0)
|
|
26
|
+
return domain[0];
|
|
27
|
+
// Snap to the nearest slot's centre, clamped to a real slot.
|
|
28
|
+
const i = Math.min(n - 1, Math.max(0, Math.floor(v - domain[0])));
|
|
29
|
+
return domain[0] + i + 0.5;
|
|
30
|
+
};
|
|
31
|
+
scale.ticks = () => {
|
|
32
|
+
const n = slots();
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let i = 0; i < n; i += 1)
|
|
35
|
+
out.push(domain[0] + i + 0.5);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
scale.bandwidth = () => {
|
|
39
|
+
const n = slots();
|
|
40
|
+
if (n === 0)
|
|
41
|
+
return 0;
|
|
42
|
+
return Math.abs((range[1] - range[0]) / n);
|
|
43
|
+
};
|
|
44
|
+
scale.step = scale.bandwidth;
|
|
45
|
+
scale.label = (value) => {
|
|
46
|
+
const i = Math.floor(value - domain[0]);
|
|
47
|
+
return i >= 0 && i < categories.length ? categories[i] : '';
|
|
48
|
+
};
|
|
49
|
+
scale.tickFormat = () => scale.label;
|
|
50
|
+
function domainFn(next) {
|
|
51
|
+
if (next === undefined)
|
|
52
|
+
return [domain[0], domain[1]];
|
|
53
|
+
domain = [next[0], next[1]];
|
|
54
|
+
return scale;
|
|
55
|
+
}
|
|
56
|
+
scale.domain = domainFn;
|
|
57
|
+
function rangeFn(next) {
|
|
58
|
+
if (next === undefined)
|
|
59
|
+
return [range[0], range[1]];
|
|
60
|
+
range = [next[0], next[1]];
|
|
61
|
+
return scale;
|
|
62
|
+
}
|
|
63
|
+
scale.range = rangeFn;
|
|
64
|
+
scale.copy = () => scaleBand(categories).domain(domain).range(range);
|
|
65
|
+
return scale;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=bandScale.js.map
|
package/dist/bars.d.ts
CHANGED
|
@@ -118,18 +118,33 @@ export interface StackStyle {
|
|
|
118
118
|
readonly fills: readonly string[];
|
|
119
119
|
readonly opacity: number;
|
|
120
120
|
readonly outlineWidth: number;
|
|
121
|
+
/**
|
|
122
|
+
* Optional **per-bin** fill override, aligned index-for-index to the bins
|
|
123
|
+
* (bin `b` uses `binFills[b]`), taking precedence over the per-group
|
|
124
|
+
* {@link fills} for that whole bin. This is the single-series band case —
|
|
125
|
+
* colour each bar by its category (heart-rate / power zones, value bands) —
|
|
126
|
+
* so it's normally paired with a `G === 1` stack. A `null`/`undefined` entry
|
|
127
|
+
* falls back to the group fill.
|
|
128
|
+
*/
|
|
129
|
+
readonly binFills?: readonly (string | undefined)[];
|
|
121
130
|
}
|
|
122
131
|
/** The narrowed selection / hover identity a stacked segment matches against:
|
|
123
|
-
* the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
|
|
132
|
+
* the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
|
|
133
|
+
* When the series carries `marks` (the categorical axis), the match keys on the
|
|
134
|
+
* stable `mark` (the column name) instead of the `key` slot index. */
|
|
124
135
|
export interface StackMark {
|
|
125
136
|
readonly id: string;
|
|
126
137
|
readonly key: number;
|
|
127
138
|
readonly label: string;
|
|
139
|
+
readonly mark?: string;
|
|
128
140
|
}
|
|
129
141
|
/**
|
|
130
|
-
* The `[min, max]` extent of the **value (stacked) axis
|
|
131
|
-
* where `maxTotal` is the tallest bin's summed finite
|
|
132
|
-
*
|
|
142
|
+
* The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
|
|
143
|
+
* stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
|
|
144
|
+
* non-negative segments. For a **single-group** series (`G === 1` — the plain /
|
|
145
|
+
* categorical bar case) it spans the values' own `[min, max]`, so a **negative**
|
|
146
|
+
* bar's floor is in the domain (segments below the baseline stay visible). `0` is
|
|
147
|
+
* always pulled in so the bars rest on a visible baseline (the bar analog of
|
|
133
148
|
* {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
|
|
134
149
|
* has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
|
|
135
150
|
* auto-fit for a horizontal one.
|
|
@@ -162,8 +177,10 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
|
|
|
162
177
|
/**
|
|
163
178
|
* Fill every segment of every bin in `ss`, stacking each bin's groups from the
|
|
164
179
|
* value baseline outward (bottom → top vertical, left → right horizontal). A gap
|
|
165
|
-
* (non-finite
|
|
166
|
-
* total, so the segments above it close the space
|
|
180
|
+
* (non-finite, or a negative segment of a true multi-group stack) is skipped and
|
|
181
|
+
* adds nothing to the running total, so the segments above it close the space; a
|
|
182
|
+
* single-group series draws its negative bars below the baseline (see
|
|
183
|
+
* {@link segmentRect}). A segment matching the current
|
|
167
184
|
* `selection` (same series `id`, bin `key` **and** group `label`) draws in its
|
|
168
185
|
* group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
|
|
169
186
|
* without the outline; all others use the flat `fill`. `globalAlpha` carries the
|