@pond-ts/charts 0.55.0 → 0.57.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 +288 -1
- package/dist/AreaChart.d.ts +18 -0
- package/dist/AreaChart.js +24 -1
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +113 -12
- package/dist/ChartContainer.d.ts +44 -1
- package/dist/ChartContainer.js +18 -2
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +14 -1
- package/dist/YAxis.d.ts +56 -1
- package/dist/YAxis.js +28 -3
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +139 -4
- package/dist/bars.js +300 -33
- package/dist/context.d.ts +30 -5
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/line.js +10 -1
- package/dist/theme.d.ts +73 -6
- package/dist/theme.js +5 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- package/package.json +3 -3
package/dist/annotations.js
CHANGED
|
@@ -6,7 +6,8 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* User-authored **annotations** — marks you place *on* a chart, in a register
|
|
8
8
|
* deliberately distinct from the data: `<Region>` (a shaded x-span), `<Baseline>`
|
|
9
|
-
* (a horizontal value line),
|
|
9
|
+
* (a horizontal value line), `<Marker>` (a vertical x line), and `<Zone>` (a
|
|
10
|
+
* shaded y-span — `<Region>`'s counterpart on the value axis). All four render
|
|
10
11
|
* in the theme's turquoise {@link ChartTheme.annotation} register so a placed mark
|
|
11
12
|
* never reads as data ("the data stays foam; the marks you place are turquoise").
|
|
12
13
|
*
|
|
@@ -101,7 +102,7 @@ const overlayStyle = {
|
|
|
101
102
|
* Read the container + row frames an annotation needs (throw if misplaced), and
|
|
102
103
|
* resolve the mark's annotation style for its optional `role`. A `role` recolors
|
|
103
104
|
* *this* mark from the theme's `annotation.roles[role]` map (`color`, and
|
|
104
|
-
* optionally `fillOpacity`) while keeping the shared depth ramp — so a smile can
|
|
105
|
+
* optionally `fillOpacity` / `dash`) while keeping the shared depth ramp — so a smile can
|
|
105
106
|
* place a green ATM baseline, a neutral vertical, and a distinct marker at once
|
|
106
107
|
* without splitting the whole register. An unknown / unset role falls back to
|
|
107
108
|
* the base `annotation` register (`roles[role] ?? annotation`).
|
|
@@ -117,17 +118,23 @@ function useAnnotationFrame(name, role) {
|
|
|
117
118
|
}
|
|
118
119
|
const base = container.theme.annotation ?? DEFAULT_ANNOTATION;
|
|
119
120
|
const roleStyle = role !== undefined ? base.roles?.[role] : undefined;
|
|
120
|
-
// The role overrides only colour (+ optional fill); depth stays the
|
|
121
|
-
// ramp, so selection / hover / edit levels read identically per role.
|
|
121
|
+
// The role overrides only colour (+ optional fill / dash); depth stays the
|
|
122
|
+
// shared ramp, so selection / hover / edit levels read identically per role.
|
|
122
123
|
const ann = roleStyle
|
|
123
124
|
? {
|
|
124
125
|
...base,
|
|
125
126
|
color: roleStyle.color,
|
|
126
127
|
fillOpacity: roleStyle.fillOpacity ?? base.fillOpacity,
|
|
128
|
+
dash: roleStyle.dash ?? base.dash,
|
|
127
129
|
}
|
|
128
130
|
: base;
|
|
129
131
|
return { container, row, ann };
|
|
130
132
|
}
|
|
133
|
+
/** The register's dash as an SVG `stroke-dasharray`, or `undefined` for solid
|
|
134
|
+
* (an empty pattern means solid, matching `LineStyle.dash`). */
|
|
135
|
+
function dashArray(dash) {
|
|
136
|
+
return dash === undefined || dash.length === 0 ? undefined : dash.join(' ');
|
|
137
|
+
}
|
|
131
138
|
/** Register this annotation with the container (so it can draw the mark's guide on
|
|
132
139
|
* other rows, order regions, and serve snap targets), keyed by the caller's stable
|
|
133
140
|
* per-instance slot key; unregister on unmount. `xs` should be memoised by the
|
|
@@ -504,7 +511,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
504
511
|
// The staff (vertical line) hangs from the top of its flag — so a flag stacked
|
|
505
512
|
// into a lower lane doesn't leave line poking above it. No label ⇒ full height.
|
|
506
513
|
const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
|
|
507
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
|
|
514
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
|
|
508
515
|
+container.xScale.invert(px)) }))] }), chipLabel && x >= 0 && x <= container.plotWidth && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
509
516
|
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
510
517
|
...flagChipX(x, container.plotWidth),
|
|
@@ -552,7 +559,7 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
|
|
|
552
559
|
const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
|
|
553
560
|
// Handle pill near the right end (clears the left-anchored label).
|
|
554
561
|
const handleX = w - 14;
|
|
555
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
562
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
556
563
|
top: `${y}px`,
|
|
557
564
|
[labelSide === 'right' ? 'right' : 'left']: '2px',
|
|
558
565
|
// `center` rides on the line; `above` sits its bottom edge on the line.
|
|
@@ -570,6 +577,89 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
|
|
|
570
577
|
}, children: fmt ? fmt(value) : String(value) }));
|
|
571
578
|
})()] }));
|
|
572
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* A shaded band between two **y values**, spanning the full plot width — the
|
|
582
|
+
* y-axis counterpart of `<Region>`. The mark for a **classification of the value
|
|
583
|
+
* axis**: US EPA AQI categories, heart-rate / power zones, a control chart's
|
|
584
|
+
* spec limits, an SLO band.
|
|
585
|
+
*
|
|
586
|
+
* Being a `<Layers>` child it lives in its row and is scaled by that row's y
|
|
587
|
+
* axis — pass `axis` to pick one on a dual-axis row. Like every annotation it
|
|
588
|
+
* paints in the SVG overlay **above** the data canvas, so keep the fill light
|
|
589
|
+
* (the register's `fillOpacity`, ~0.1–0.2) and the trace reads cleanly through
|
|
590
|
+
* it. A zone set is a wash of colour behind the story, not a layer competing
|
|
591
|
+
* with it.
|
|
592
|
+
*
|
|
593
|
+
* Zones are **background context by default** (`selectable={false}`,
|
|
594
|
+
* `edges={false}`, no label) because that is what a tiled zone set is; see
|
|
595
|
+
* {@link ZoneProps.selectable} for why the family's usual defaults invert here.
|
|
596
|
+
* Colour comes from the theme's {@link ZoneProps.role | role} map, so a palette
|
|
597
|
+
* is a theme, not six call-site colours.
|
|
598
|
+
*
|
|
599
|
+
* Unlike the other marks a zone has **no `onChange`** — dragging zone edges
|
|
600
|
+
* (a zone editor) is a real feature but has no consumer yet; the band is
|
|
601
|
+
* declarative until one arrives.
|
|
602
|
+
*/
|
|
603
|
+
export function Zone({ from, to, axis, label, labelSide = 'left', id, selected = false, selectable = false, hovered, role, edges = false, }) {
|
|
604
|
+
const { container, row, ann } = useAnnotationFrame('Zone', role);
|
|
605
|
+
const selfKey = useSlotKey();
|
|
606
|
+
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
607
|
+
// A horizontal band casts no vertical guide (like a baseline) — register with
|
|
608
|
+
// no xs, so it's tracked for ordering but offers no snap target.
|
|
609
|
+
const xs = useMemo(() => [], []);
|
|
610
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'zone', xs, selected, selectable, false, // zones aren't editable — no single-annotation edit state
|
|
611
|
+
label ?? '', false);
|
|
612
|
+
// No select while a create tool is armed — the chart is in draw mode then.
|
|
613
|
+
const select = id !== undefined && container.creating === null
|
|
614
|
+
? () => container.onSelectAnnotation?.(id)
|
|
615
|
+
: undefined;
|
|
616
|
+
const axisId = axis ?? row.defaultAxisId;
|
|
617
|
+
const yScale = row.yScales.get(axisId);
|
|
618
|
+
// The axis may not have resolved yet (a layer mounts before its <YAxis>); skip
|
|
619
|
+
// until its scale exists rather than guessing a domain.
|
|
620
|
+
if (yScale === undefined)
|
|
621
|
+
return null;
|
|
622
|
+
const h = row.height;
|
|
623
|
+
const w = container.plotWidth;
|
|
624
|
+
// A NaN bound has no position to draw at — cull rather than let it fall
|
|
625
|
+
// through the finite check below and silently become the domain's low end.
|
|
626
|
+
if (Number.isNaN(from) || Number.isNaN(to))
|
|
627
|
+
return null;
|
|
628
|
+
// Open-ended bounds resolve against the axis **domain** before scaling, not
|
|
629
|
+
// after: d3's interpolator is `a·(1−t) + b·t`, so an infinite `t` yields
|
|
630
|
+
// `0 · Infinity` = NaN rather than an off-plot pixel. Substituting the domain
|
|
631
|
+
// end is also exactly the intent — an open band reaches the plot edge.
|
|
632
|
+
const [d0, d1] = yScale.domain();
|
|
633
|
+
const dLo = Math.min(d0 ?? 0, d1 ?? 0);
|
|
634
|
+
const dHi = Math.max(d0 ?? 0, d1 ?? 0);
|
|
635
|
+
const bound = (v) => (Number.isFinite(v) ? v : v > 0 ? dHi : dLo);
|
|
636
|
+
// Order + clamp to the plot: a band may run past the axis domain (the AQI
|
|
637
|
+
// 151–200 band on an axis topping out at 120). Clamping keeps the rect inside
|
|
638
|
+
// its row; a band entirely outside culls.
|
|
639
|
+
const lo = Math.min(from, to);
|
|
640
|
+
const hi = Math.max(from, to);
|
|
641
|
+
const loY = yScale(bound(lo));
|
|
642
|
+
const hiY = yScale(bound(hi));
|
|
643
|
+
const top = Math.max(Math.min(loY, hiY), 0);
|
|
644
|
+
const bottom = Math.min(Math.max(loY, hiY), h);
|
|
645
|
+
if (bottom <= top)
|
|
646
|
+
return null;
|
|
647
|
+
const bandH = bottom - top;
|
|
648
|
+
// Edit is never on (no onChange) — the levels reduce to selected / hover /
|
|
649
|
+
// resting, and to a flat level 3 when the band is inert background.
|
|
650
|
+
const edgeOpacity = rampAt(ann.depth, lineLevel(selectable, false, hovering, selected));
|
|
651
|
+
const fillOpacity = ann.fillOpacity *
|
|
652
|
+
rampAt(FILL_MULT, bodyLevel(selectable, false, hovering, selected));
|
|
653
|
+
/** A boundary line — drawn only where the band has a *real*, in-plot edge. An
|
|
654
|
+
* open-ended bound has no boundary at all, and a bound the clamp cut off would
|
|
655
|
+
* otherwise draw its line on the plot border, reading as chrome. */
|
|
656
|
+
const boundary = (v, aty) => edges && Number.isFinite(v) && aty >= 0 && aty <= h ? (_jsx("line", { x1: 0, y1: aty, x2: w, y2: aty, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" })) : null;
|
|
657
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: h, style: overlayStyle, children: [_jsx("rect", { x: 0, y: top, width: w, height: bandH, fill: ann.color, opacity: fillOpacity }), boundary(lo, loY), boundary(hi, hiY), selectable && (_jsx(DragArea, { x: 0, y: top, w: w, h: bandH, cursor: "inherit", editable: false, onHover: reportHover, onSelect: select, onDrag: () => { } }))] }), label !== undefined && label !== '' && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
658
|
+
top: `${top + bandH / 2}px`,
|
|
659
|
+
[labelSide === 'right' ? 'right' : 'left']: '2px',
|
|
660
|
+
transform: 'translateY(-50%)',
|
|
661
|
+
}, children: label }))] }));
|
|
662
|
+
}
|
|
573
663
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
574
664
|
* flies as a flag off the left edge. */
|
|
575
665
|
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, role, }) {
|
|
@@ -616,7 +706,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
616
706
|
// even after the dragged edge crosses it — {@link orderRegion} then re-opens the
|
|
617
707
|
// region the other way instead of dead-ending at zero width.
|
|
618
708
|
const edgeRef = useRef(null);
|
|
619
|
-
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
|
|
709
|
+
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }));
|
|
620
710
|
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: (px) => {
|
|
621
711
|
dragRef.current = { from, to, startPx: px };
|
|
622
712
|
}, onDrag: (px) => {
|
package/dist/area.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { area as d3area, curveLinear } from 'd3-shape';
|
|
2
2
|
import { strokeAffinePolyline } from './line.js';
|
|
3
|
-
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
3
|
+
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
4
4
|
import { cullChartSeries } from './culling.js';
|
|
5
5
|
import { decimateM4Cached } from './decimate.js';
|
|
6
6
|
import { affineOf } from './affine.js';
|
|
@@ -191,6 +191,15 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
|
|
|
191
191
|
else {
|
|
192
192
|
cs = cullChartSeries(source, xScale);
|
|
193
193
|
}
|
|
194
|
+
// Values with no position on the y scale (zero / negative on a log axis)
|
|
195
|
+
// become ordinary NaN gaps, so the fill and outline break at them rather than
|
|
196
|
+
// bridging over a dropped `lineTo(x, NaN)`. Deliberately **after** the
|
|
197
|
+
// gradient above: that reads the pre-cull buffer, whose finite extent is
|
|
198
|
+
// memoized per `Float64Array` ([PND-GRADX]), and a fresh array here would miss
|
|
199
|
+
// that cache on every frame. A no-op on an affine (linear) y scale.
|
|
200
|
+
const scaledY = gapUnscalable(cs.y, cs.length, yScale);
|
|
201
|
+
if (scaledY !== cs.y)
|
|
202
|
+
cs = { ...cs, y: scaledY };
|
|
194
203
|
// `none` interpolates interior gaps so the fill + outline bridge them; every
|
|
195
204
|
// other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
|
|
196
205
|
// a separate overlay pass below).
|
|
@@ -277,20 +286,42 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
|
|
|
277
286
|
function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
|
|
278
287
|
if (valueExtent === null)
|
|
279
288
|
return style.fill; // no finite values (caller no-ops)
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
// Stacked areas opt out of the grade entirely: a band that fades to
|
|
290
|
+
// transparent at the baseline shows every band beneath it (see AreaStyle).
|
|
291
|
+
if (style.flatFill === true)
|
|
292
|
+
return style.fill;
|
|
293
|
+
// The pixel extent is the two value extremes mapped through the (monotonic)
|
|
294
|
+
// y scale; min/max them so the result is flip-agnostic, exactly as the former
|
|
295
|
+
// per-point pixel scan produced. [PND-GRADX] moved the O(N) walk into the
|
|
296
|
+
// memoized {@link columnFiniteExtent}.
|
|
297
|
+
//
|
|
298
|
+
// **An extreme with no position on the scale is dropped**, not min/maxed in.
|
|
299
|
+
// `valueExtent` is the data's own `[min, max]`, and on a **log** axis a
|
|
300
|
+
// non-positive extreme — a series that touches zero, which is the ordinary
|
|
301
|
+
// shape of traffic or storage data — maps to `NaN`. `Math.min(NaN, pb)` is
|
|
302
|
+
// `NaN`, `NaN` propagates to the height, and `NaN < 1e-6` is **false**, so the
|
|
303
|
+
// degenerate guard below waved it through to `createLinearGradient(0, NaN, 0,
|
|
304
|
+
// NaN)` — which throws `IndexSizeError` on a real canvas and takes the whole
|
|
305
|
+
// chart down. The region is seeded from the baseline pixel (always in-domain,
|
|
306
|
+
// via `resolveAreaBaseline`) and widened only by extremes that have a
|
|
307
|
+
// position, so the grade still spans the part of the series that draws.
|
|
308
|
+
let regionTop = baselinePx;
|
|
309
|
+
let regionBottom = baselinePx;
|
|
310
|
+
const widen = (px) => {
|
|
311
|
+
if (!Number.isFinite(px))
|
|
312
|
+
return;
|
|
313
|
+
if (px < regionTop)
|
|
314
|
+
regionTop = px;
|
|
315
|
+
if (px > regionBottom)
|
|
316
|
+
regionBottom = px;
|
|
317
|
+
};
|
|
318
|
+
widen(yScale(valueExtent[0]));
|
|
319
|
+
widen(yScale(valueExtent[1]));
|
|
320
|
+
// `!(… >= 1e-6)` rather than `< 1e-6`, so a non-finite height — a baseline
|
|
321
|
+
// that somehow has no position either, leaving nothing finite to anchor on —
|
|
322
|
+
// falls back to the flat fill instead of reaching the gradient calls.
|
|
323
|
+
if (!(regionBottom - regionTop >= 1e-6))
|
|
324
|
+
return style.fill; // degenerate
|
|
294
325
|
const opaque = style.fill;
|
|
295
326
|
const transparent = withAlpha(style.fill, 0);
|
|
296
327
|
const grad = ctx.createLinearGradient(0, regionTop, 0, regionBottom);
|
package/dist/band.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { area as d3area, curveLinear } from 'd3-shape';
|
|
2
2
|
import { cullBandSeries } from './culling.js';
|
|
3
3
|
import { decimateBand } from './decimate.js';
|
|
4
|
+
import { gapUnscalable } from './gaps.js';
|
|
4
5
|
/**
|
|
5
6
|
* The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
|
|
6
7
|
* and highest `upper` over samples where both edges are finite — or `null` if
|
|
@@ -60,6 +61,18 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
|
|
|
60
61
|
band = decimateBand(band, xScale, ctx, k);
|
|
61
62
|
decimated = band !== before;
|
|
62
63
|
}
|
|
64
|
+
// An edge with no position on the y scale becomes an ordinary NaN gap, so the
|
|
65
|
+
// envelope breaks there rather than emitting dropped path ops that stitch the
|
|
66
|
+
// neighbouring samples together. A `lower` of `0` is the common shape — a band
|
|
67
|
+
// measured from nothing — and on a log axis zero has no position, so without
|
|
68
|
+
// this the fill silently spanned the samples it could not draw. Gapping either
|
|
69
|
+
// edge gaps the sample, which is already the band's contract: a sample counts
|
|
70
|
+
// only where **both** edges do. A no-op on an affine (linear) y scale.
|
|
71
|
+
const gapLower = gapUnscalable(band.lower, band.length, yScale);
|
|
72
|
+
const gapUpper = gapUnscalable(band.upper, band.length, yScale);
|
|
73
|
+
if (gapLower !== band.lower || gapUpper !== band.upper) {
|
|
74
|
+
band = { ...band, lower: gapLower, upper: gapUpper };
|
|
75
|
+
}
|
|
63
76
|
const gen = d3area()
|
|
64
77
|
.defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
|
|
65
78
|
.x((_, i) => xScale(band.x[i]))
|
package/dist/bars.d.ts
CHANGED
|
@@ -56,6 +56,84 @@ export declare function resolveBarBaseline(yScale: Scale): number;
|
|
|
56
56
|
* separates columns visually without carving a dead channel out of the target.
|
|
57
57
|
*/
|
|
58
58
|
export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
|
|
59
|
+
/**
|
|
60
|
+
* The value-space span `[lo, hi]` of **threshold band `k`** along a bar running
|
|
61
|
+
* from `base` to `v`, or `null` when the bar doesn't reach that band.
|
|
62
|
+
*
|
|
63
|
+
* A threshold ladder colours one bar **along its length** — neutral up to the
|
|
64
|
+
* first threshold, then warning, then alarm — so a long bar shows how far
|
|
65
|
+
* through the ladder it travelled rather than only which band it ended in. With
|
|
66
|
+
* `thresholds = [t0, t1]` there are three bands: `[0, t0)`, `[t0, t1)`,
|
|
67
|
+
* `[t1, ∞)`. Band `k` spans magnitudes `[thresholds[k-1] ?? 0, thresholds[k] ??
|
|
68
|
+
* ∞)`, each end clipped to the bar's own magnitude — so a bar that stops inside
|
|
69
|
+
* band 1 yields a truncated band 1 and `null` for band 2.
|
|
70
|
+
*
|
|
71
|
+
* **Breakpoints are absolute data values, not offsets from the baseline** — a
|
|
72
|
+
* `thresholds={[1, 2]}` ladder means "warning above 1, alarm above 2" in the
|
|
73
|
+
* axis's own units, which is what a threshold means everywhere else. They are
|
|
74
|
+
* matched on the **magnitude** and applied to whichever side of zero the bar
|
|
75
|
+
* is on, so a bar hanging below the baseline walks the same ladder downward
|
|
76
|
+
* and a ±3.5 diverging scale bands symmetrically without the caller supplying
|
|
77
|
+
* negative breakpoints. (An asymmetric ladder would need signed breakpoints;
|
|
78
|
+
* deferred until a consumer pulls — see [PND-BANDBAR2].)
|
|
79
|
+
*
|
|
80
|
+
* The painted span is then **clipped to what the bar actually draws**, which
|
|
81
|
+
* is what makes a domain that excludes zero behave: with `<YAxis min={10}>` a
|
|
82
|
+
* bar rests on 10, so a `[1, 2]` ladder leaves it entirely in the top band
|
|
83
|
+
* rather than banding at 11 and 12. Measuring the ladder from the *resolved
|
|
84
|
+
* baseline* instead would silently shift every breakpoint by the axis floor —
|
|
85
|
+
* exactly the class of quiet wrongness this feature exists to remove.
|
|
86
|
+
*
|
|
87
|
+
* Note this is **draw-only geometry**. Hit-testing still treats the bar as one
|
|
88
|
+
* target ({@link barSlotRect} / {@link barAt}), which is the whole reason this
|
|
89
|
+
* is a mark rather than the N-layer overpaint recipe it replaces: one bar keeps
|
|
90
|
+
* one hit region, one stable `mark`, and one legend row.
|
|
91
|
+
*
|
|
92
|
+
* `thresholds` is assumed ascending and finite — {@link normalizeThresholds}
|
|
93
|
+
* enforces that once at the prop boundary rather than per bar per frame.
|
|
94
|
+
*/
|
|
95
|
+
export declare function bandSpan(base: number, v: number, thresholds: readonly number[], k: number): [lo: number, hi: number] | null;
|
|
96
|
+
/**
|
|
97
|
+
* A resolved threshold ladder: ascending `thresholds` (from
|
|
98
|
+
* {@link normalizeThresholds}) paired with the `colors` each band draws in,
|
|
99
|
+
* `colors[k]` for the band above `thresholds[k - 1]`. Assembled by `BarChart`
|
|
100
|
+
* from `<BarChart bandColors>` → {@link BarStyle.bands}, so — like
|
|
101
|
+
* {@link StackStyle} — the draw layer stays theme-free and unit-testable.
|
|
102
|
+
*
|
|
103
|
+
* `colors` is guaranteed `thresholds.length + 1` long by the time it reaches a
|
|
104
|
+
* draw path; a short ladder is resolved (and warned about) at the boundary.
|
|
105
|
+
*/
|
|
106
|
+
export interface BandLadder {
|
|
107
|
+
readonly thresholds: readonly number[];
|
|
108
|
+
readonly colors: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Validate + freeze a caller's threshold ladder once, at the prop boundary.
|
|
112
|
+
* Returns the ascending, strictly-positive, finite breakpoints — or `null` when
|
|
113
|
+
* there is no usable ladder left, so the caller keeps the flat path.
|
|
114
|
+
*
|
|
115
|
+
* Sorting rather than rejecting an out-of-order ladder is deliberate — the
|
|
116
|
+
* bands are defined by their boundaries, so `[2, 1]` and `[1, 2]` describe the
|
|
117
|
+
* same three bands and there is no second reading to guess at.
|
|
118
|
+
*
|
|
119
|
+
* Three kinds of entry are **dropped**:
|
|
120
|
+
*
|
|
121
|
+
* - **non-finite** — would swallow every band above it;
|
|
122
|
+
* - **negative** — the ladder is walked on the *magnitude* and mirrored onto
|
|
123
|
+
* whichever side of zero the bar is on, so a negative breakpoint has no
|
|
124
|
+
* meaning. Left in, `[-2, -1]` silently clipped every lower band away and
|
|
125
|
+
* painted the whole bar in the final colour — a one-colour bar that looks
|
|
126
|
+
* deliberate (Codex adversarial review). Signed breakpoints are the
|
|
127
|
+
* asymmetric-ladder feature deferred in [PND-BANDBAR2], not this;
|
|
128
|
+
* - **zero** — band 0 already starts at zero, so a `0` breakpoint describes an
|
|
129
|
+
* empty band and shifts every colour by one.
|
|
130
|
+
*
|
|
131
|
+
* Dropping rather than throwing matches how the rest of this prop behaves
|
|
132
|
+
* (a short colour ladder degrades, it doesn't fail), and `BarChart` dev-warns
|
|
133
|
+
* whenever normalization removed anything — a silently-ignored breakpoint is
|
|
134
|
+
* the failure mode this whole feature exists to avoid.
|
|
135
|
+
*/
|
|
136
|
+
export declare function normalizeThresholds(thresholds: readonly number[] | undefined): readonly number[] | null;
|
|
59
137
|
/**
|
|
60
138
|
* The narrowed selection / hover identity a **single-series** bar matches
|
|
61
139
|
* against: the layer's series `id`, the sample's `key` (its `begin`), and — when
|
|
@@ -120,7 +198,7 @@ export interface BarMark {
|
|
|
120
198
|
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
121
199
|
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
122
200
|
*/
|
|
123
|
-
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[]): LayerDrawStats;
|
|
201
|
+
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[], banding?: BandLadder): LayerDrawStats;
|
|
124
202
|
/**
|
|
125
203
|
* The index of the bar whose key span `[begin, end]` contains `time` — the bar
|
|
126
204
|
* **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
|
|
@@ -222,6 +300,41 @@ export interface StackStyle {
|
|
|
222
300
|
* falls back to the group fill.
|
|
223
301
|
*/
|
|
224
302
|
readonly binFills?: readonly (string | undefined)[];
|
|
303
|
+
/**
|
|
304
|
+
* The **selected** segment's fill, and `hover` the pointer-over one — the
|
|
305
|
+
* three-step `fill → hover → highlight` emphasis {@link BarStyle} has always
|
|
306
|
+
* carried and this path used to ignore ([PND-CATEMPH]).
|
|
307
|
+
*
|
|
308
|
+
* **Only applied when there is no meaning-carrying colour to destroy**, i.e.
|
|
309
|
+
* when {@link binFills} is unset. A per-bin-coloured bar keeps its own colour
|
|
310
|
+
* and pops {@link emphasisOpacity} instead — swapping a zone-coloured or
|
|
311
|
+
* direction-coloured bar to one highlight hue would erase what the colour
|
|
312
|
+
* encodes, which is the one *design* exclusion rather than a path accident.
|
|
313
|
+
*
|
|
314
|
+
* The friction this closes wasn't the behaviour, which is defensible: it was
|
|
315
|
+
* that `theme.bar.hover` / `.highlight` were typed, settable, documented as
|
|
316
|
+
* the emphasis channel, and silently did nothing on the most common
|
|
317
|
+
* categorical chart. A theme author set them, saw no change, and had no way
|
|
318
|
+
* to tell whether they were wrong about the colour or about the mechanism.
|
|
319
|
+
*/
|
|
320
|
+
readonly highlight?: string;
|
|
321
|
+
/** See {@link highlight}. Falls back to `highlight` when unset. */
|
|
322
|
+
readonly hover?: string;
|
|
323
|
+
/**
|
|
324
|
+
* Stroke for the selected segment's outline. Defaults to the segment's own
|
|
325
|
+
* resolved fill (the shipped behaviour). Set it to give the category path a
|
|
326
|
+
* themed selection cue that works even where the fill can't change — the
|
|
327
|
+
* `binFills` case, where the alpha pop is otherwise the only signal.
|
|
328
|
+
*/
|
|
329
|
+
readonly selectedOutline?: string;
|
|
330
|
+
/**
|
|
331
|
+
* The alpha a hovered / selected segment pops to. **Default `1`** (the
|
|
332
|
+
* shipped behaviour). Lower it for a subtler emphasis on a dense stack —
|
|
333
|
+
* previously the pop was hard-coded and the only tunable was the resting
|
|
334
|
+
* {@link opacity}, so a theme could not adjust the *difference* between
|
|
335
|
+
* resting and live, only the floor.
|
|
336
|
+
*/
|
|
337
|
+
readonly emphasisOpacity?: number;
|
|
225
338
|
}
|
|
226
339
|
/** The narrowed selection / hover identity a stacked segment matches against:
|
|
227
340
|
* the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
|
|
@@ -235,14 +348,20 @@ export interface StackMark {
|
|
|
235
348
|
}
|
|
236
349
|
/**
|
|
237
350
|
* The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
|
|
238
|
-
* stack it is `[
|
|
239
|
-
*
|
|
351
|
+
* stack it is `[minNegTotal, maxPosTotal]` — each bin's positive segments summed
|
|
352
|
+
* upward and its negative segments summed downward, tracked separately
|
|
353
|
+
* ([PND-SIGNSTACK]). For a **single-group** series (`G === 1` — the plain /
|
|
240
354
|
* categorical bar case) it spans the values' own `[min, max]`, so a **negative**
|
|
241
355
|
* bar's floor is in the domain (segments below the baseline stay visible). `0` is
|
|
242
356
|
* always pulled in so the bars rest on a visible baseline (the bar analog of
|
|
243
357
|
* {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
|
|
244
358
|
* has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
|
|
245
359
|
* auto-fit for a horizontal one.
|
|
360
|
+
*
|
|
361
|
+
* The negative half is new: this used to sum only positives, matching a draw
|
|
362
|
+
* path that dropped negative segments outright. Both halves changed together —
|
|
363
|
+
* an extent that stopped at `0` below would clip the very segments the draw
|
|
364
|
+
* path now emits.
|
|
246
365
|
*/
|
|
247
366
|
export declare function stackValueExtent(ss: StackedBarSeries): [number, number];
|
|
248
367
|
/**
|
|
@@ -251,6 +370,22 @@ export declare function stackValueExtent(ss: StackedBarSeries): [number, number]
|
|
|
251
370
|
* the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
|
|
252
371
|
*/
|
|
253
372
|
export declare function stackBinExtent(ss: StackedBarSeries): [number, number] | null;
|
|
373
|
+
/**
|
|
374
|
+
* The value a stack's **first** segment rests on, in data units — the same
|
|
375
|
+
* `0`-clamped-into-the-domain rule {@link resolveBarBaseline} applies to a plain
|
|
376
|
+
* bar, read off whichever scale carries the stacked value (`yScale` when the
|
|
377
|
+
* bars grow up, `xScale` when they grow right).
|
|
378
|
+
*
|
|
379
|
+
* Both stack walks used to start at a literal `0`, which is right only while the
|
|
380
|
+
* domain contains zero — and a **log** domain never can. `yScale(0)` on a log
|
|
381
|
+
* scale is `NaN`, `fillRect` with a `NaN` argument is a silent canvas no-op, and
|
|
382
|
+
* the same rect feeds {@link stackAt} — so the bottom segment of every stack
|
|
383
|
+
* both vanished *and* became unhittable, with nothing to see but a stack that
|
|
384
|
+
* starts one segment up. The linear case is unaffected: the value extents pull
|
|
385
|
+
* `0` into the domain, so this returns exactly `0` and the geometry is
|
|
386
|
+
* unchanged.
|
|
387
|
+
*/
|
|
388
|
+
export declare function stackBase(orientation: Orientation, xScale: Scale, yScale: Scale): number;
|
|
254
389
|
/**
|
|
255
390
|
* The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
|
|
256
391
|
* segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
|
|
@@ -283,7 +418,7 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
|
|
|
283
418
|
*
|
|
284
419
|
* O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
|
|
285
420
|
*/
|
|
286
|
-
export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: StackMark | null, hover: StackMark | null): void;
|
|
421
|
+
export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: StackMark | null, hover: StackMark | null, banding?: BandLadder): void;
|
|
287
422
|
/**
|
|
288
423
|
* Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
|
|
289
424
|
* segment whose rect contains the point, or `null`. The geometry is
|