@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/dist/BarChart.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { Interval, ValueSeries } from 'pond-ts';
3
3
  import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
- import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
4
+ import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
+ import { isDev } from './dev.js';
5
6
  import { ContainerContext, LayersContext, } from './context.js';
6
7
  import { legendLabelFor, useLegendItems, } from './swatch.js';
7
8
  import { useSlotKey } from './use-slot-key.js';
@@ -24,14 +25,21 @@ import { useSlotKey } from './use-slot-key.js';
24
25
  * domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
25
26
  * above zero (see {@link resolveBarBaseline}).
26
27
  *
27
- * **Baseline (stacked).** A stack is **cumulative from value 0** — the segments
28
- * sum upward from the zero line, so its value axis **must include 0**. The
29
- * auto-fit guarantees this: {@link stackValueExtent} always returns `[0, maxTotal]`.
30
- * An explicit `<YAxis min>` **above** 0 is therefore unsupported for a stack — it
31
- * would hide the bottom of the cumulative column; only the portion above the floor
32
- * draws (clipped cleanly at the plot floor, as any bar below an explicit floor is).
33
- * Segment values are assumed **non-negative** (a negative or zero segment is
34
- * skipped — diverging stacks are out of scope).
28
+ * **Baseline (stacked).** A stack is **cumulative from value 0** — so its value
29
+ * axis **must include 0**. The auto-fit guarantees this:
30
+ * {@link stackValueExtent} returns `[minNegativeTotal, maxPositiveTotal]`, both
31
+ * seeded at `0`. An explicit `<YAxis min>` **above** 0 is therefore unsupported
32
+ * for a stack — it would hide the bottom of the cumulative column; only the
33
+ * portion above the floor draws (clipped cleanly at the plot floor, as any bar
34
+ * below an explicit floor is).
35
+ *
36
+ * **Signed stacks are supported** ([PND-SIGNSTACK]): each bin keeps two running
37
+ * totals, so positive segments stack **up** from the zero line and negative
38
+ * ones stack **down** from it — the signed histogram (net flow by category,
39
+ * inflow/outflow, buy/sell pressure by venue). A **zero** segment is still
40
+ * skipped, having no extent to draw or hit-test. This changed in the
41
+ * threshold-banding wave: negative segments were previously dropped outright
42
+ * and silently, so a mixed-sign series rendered as an all-positive chart.
35
43
  *
36
44
  * **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
37
45
  * cursor (hit-tested by pixel rect, so it works in both orientations); click
@@ -48,7 +56,7 @@ import { useSlotKey } from './use-slot-key.js';
48
56
  * </Layers>
49
57
  * ```
50
58
  */
51
- export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
59
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
52
60
  const container = useContext(ContainerContext);
53
61
  if (container === null) {
54
62
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -232,6 +240,87 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
232
240
  // The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
233
241
  // role — `as` is single-series only), matching how `gapPx` sources its default.
234
242
  const stackMinWidth = bar.default.minWidth;
243
+ // ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
244
+ // Resolved once here rather than per bar per frame: normalize the breakpoints
245
+ // (sort, drop non-finite), then pair them with `bandColors` → the role's
246
+ // `BarStyle.bands`. Everything that can go wrong with the pairing is a
247
+ // *silent* wrong-looking chart, so each case dev-warns — this feature exists
248
+ // because a quietly-unbanded bar was the workaround's failure mode.
249
+ // Value-compare the two array props rather than relying on their identity.
250
+ // `thresholds={[1, 2]}` inline is the documented usage and the shape every
251
+ // story and doc example uses — and a fresh array each render would rebuild
252
+ // the ladder, hence the layer `entry` below, hence a `registerLayer` call
253
+ // **every render**. That is a repaint treadmill, not just a noisy warning.
254
+ // The same value-compare-on-registration reasoning `<YAxis ticks>` already
255
+ // applies.
256
+ const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
257
+ const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
258
+ const bandLadder = useMemo(() => {
259
+ const steps = normalizeThresholds(thresholds);
260
+ if (steps === null) {
261
+ if (isDev && thresholds !== undefined && thresholds.length > 0) {
262
+ console.warn('<BarChart thresholds>: no usable breakpoints, so no banding was ' +
263
+ 'applied — each must be finite and greater than zero. Bars draw ' +
264
+ 'in the flat fill.');
265
+ }
266
+ return undefined;
267
+ }
268
+ // Some, but not all, entries dropped. Silently banding on a subset of what
269
+ // the caller wrote is exactly the class of quiet wrongness this feature is
270
+ // meant to remove, so say so.
271
+ if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
272
+ console.warn(`<BarChart thresholds>: dropped ${thresholds.length - steps.length} ` +
273
+ 'breakpoint(s) that were not finite and greater than zero. The ' +
274
+ 'ladder is walked on the magnitude and mirrored onto whichever side ' +
275
+ 'of zero a bar is on, so a negative breakpoint has no meaning; ' +
276
+ `banding on [${steps.join(', ')}].`);
277
+ }
278
+ const want = steps.length + 1;
279
+ const supplied = bandColors ?? singleStyle.bands;
280
+ if (supplied === undefined || supplied.length === 0) {
281
+ if (isDev) {
282
+ console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
283
+ 'band colours, but neither `bandColors` nor the theme role’s ' +
284
+ '`BarStyle.bands` supplies any. Bars draw in the flat fill.');
285
+ }
286
+ return undefined;
287
+ }
288
+ if (supplied.length < want && isDev) {
289
+ console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
290
+ `band colours but only ${supplied.length} were supplied; bands ` +
291
+ 'above the last colour fall back to the flat fill.');
292
+ }
293
+ // Pad a short ladder with the flat fill so the draw path can index freely.
294
+ const resolved = supplied.length >= want
295
+ ? supplied.slice(0, want)
296
+ : [
297
+ ...supplied,
298
+ ...Array.from({ length: want - supplied.length }, () => singleStyle.fill),
299
+ ];
300
+ return { thresholds: steps, colors: resolved };
301
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
302
+ // `bandColorKey` are the value-compared stand-ins for the array props.
303
+ }, [thresholdKey, bandColorKey, singleStyle]);
304
+ // Conflicts between the ladder and the shapes it can't apply to. In an effect
305
+ // so a re-render doesn't re-log; each fires once per genuinely new pairing.
306
+ const multiGroup = shape.kind === 'stacked' && shape.ss.groups.length > 1;
307
+ const hasLadder = bandLadder !== undefined;
308
+ const hasBinColors = binColors !== undefined;
309
+ useEffect(() => {
310
+ if (!isDev || !hasLadder)
311
+ return;
312
+ if (hasBinColors) {
313
+ console.warn('<BarChart>: `thresholds` and `binColors` are both set. They are two ' +
314
+ 'answers to “what colour is this bar”; `binColors` wins as the more ' +
315
+ 'specific one, and the threshold bands are ignored.');
316
+ }
317
+ if (multiGroup) {
318
+ console.warn('<BarChart>: `thresholds` is ignored on a multi-group stack — a ' +
319
+ 'segment that is already one slice of a total has no defined ' +
320
+ 'banding. Threshold bands apply to single-value bars (`series` / ' +
321
+ '`bins` / `categories`), in either orientation.');
322
+ }
323
+ }, [hasLadder, hasBinColors, multiGroup]);
235
324
  // Stacked style: per-group fills (colors override → theme role → default),
236
325
  // plus the shared opacity / outline from the default bar style. Memoized on the
237
326
  // groups + colours so a selection change doesn't rebuild it.
@@ -243,6 +332,17 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
243
332
  fills,
244
333
  opacity: base.opacity,
245
334
  outlineWidth: base.outlineWidth,
335
+ // [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
336
+ // path can read the same `fill → hover → highlight` channel every other
337
+ // bar does, instead of accepting those theme values and ignoring them.
338
+ highlight: base.highlight,
339
+ ...(base.hover !== undefined ? { hover: base.hover } : {}),
340
+ ...(base.selectedOutline !== undefined
341
+ ? { selectedOutline: base.selectedOutline }
342
+ : {}),
343
+ ...(base.emphasisOpacity !== undefined
344
+ ? { emphasisOpacity: base.emphasisOpacity }
345
+ : {}),
246
346
  ...(binColors !== undefined ? { binFills: binColors } : {}),
247
347
  };
248
348
  }, [bar, groups, colors, binColors]);
@@ -331,7 +431,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
331
431
  };
332
432
  },
333
433
  }),
334
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors),
434
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder),
335
435
  },
336
436
  axisId: axis,
337
437
  index,
@@ -391,7 +491,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
391
491
  };
392
492
  },
393
493
  }),
394
- draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover),
494
+ draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
395
495
  },
396
496
  axisId: axis,
397
497
  index,
@@ -405,6 +505,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
405
505
  singleStyle,
406
506
  stackStyle,
407
507
  binColors,
508
+ bandLadder,
408
509
  label,
409
510
  id,
410
511
  gapPx,
@@ -14,6 +14,49 @@ export interface ChartContainerProps {
14
14
  * the data — so a tuple stays a time domain on a time chart.
15
15
  */
16
16
  range?: readonly [number, number] | TimeRange;
17
+ /**
18
+ * **Cap the slot pitch** on a **category** x axis, in CSS pixels
19
+ * ([PND-BANDPACK]). A band scale otherwise spreads its categories across the
20
+ * full plot width, so three categories in a 900px panel become three 300px
21
+ * bars and thirty become thirty 30px ones — the same chart in the same panel
22
+ * reading as two different charts depending on how many categories the data
23
+ * happened to return.
24
+ *
25
+ * That is fine for a static chart with a known domain and wrong for a **live**
26
+ * one: when the category count moves over a session, bar width becomes a
27
+ * meaningless variable that moves on its own, and a reader can't compare the
28
+ * chart to what it looked like a minute ago or to the same chart on another
29
+ * screen. Capping the pitch keeps bar width constant and comparable, and the
30
+ * empty space left over is itself information — it shows the set is small.
31
+ *
32
+ * Omitted ⇒ slots fill the plot (unchanged). When `n × maxBandWidth` exceeds
33
+ * the plot, the cap can't bind and the slots fill as before, so this degrades
34
+ * correctly as categories accumulate. Use {@link bandAlign} to say where the
35
+ * capped block sits.
36
+ *
37
+ * **This caps the slot, not the bar.** `<BarChart gap>` still insets the bar
38
+ * within its slot, and the two compose — one knob for pitch, one for ink,
39
+ * neither doing the other's job. (Inverting `gap` against a measured plot
40
+ * width was the workaround this replaces for the width half; the packing half
41
+ * had no workaround at all.)
42
+ *
43
+ * **Vertical / x-axis categories only.** A `orientation="horizontal"`
44
+ * categorical chart puts its categories on the **y** axis as unit slots,
45
+ * which is a different mechanism and is not capped by this.
46
+ */
47
+ maxBandWidth?: number;
48
+ /**
49
+ * Where the capped category block sits in the plot when {@link maxBandWidth}
50
+ * binds. **Default `'start'`** — pack from the left, leaving the far side
51
+ * empty. `'center'` and `'end'` place it otherwise.
52
+ *
53
+ * A no-op without `maxBandWidth`, or when the cap doesn't bind: the block
54
+ * fills the plot and there is no slack to place. (There is deliberately no
55
+ * `'fill'` member — "fill" is what *omitting* `maxBandWidth` means, and a
56
+ * `fill` value alongside a pitch cap would be a contradiction rather than a
57
+ * choice.)
58
+ */
59
+ bandAlign?: 'start' | 'center' | 'end';
17
60
  /**
18
61
  * A **trading-calendar** discontinuity provider — closed-market time
19
62
  * (weekends, holidays, overnight, lunch breaks) collapsed. Supply it to turn
@@ -419,5 +462,5 @@ export interface ChartContainerProps {
419
462
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
420
463
  * (`<YAxis>`).
421
464
  */
422
- export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
465
+ export declare function ChartContainer({ range, maxBandWidth, bandAlign, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
423
466
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -45,7 +45,7 @@ function normalizeRange(range) {
45
45
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
46
46
  * (`<YAxis>`).
47
47
  */
48
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
48
+ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
49
49
  // Normalize the `panZoom` mode (boolean shorthand or the three-way string)
50
50
  // into the two gesture flags the event surface reads. `true` ⇒ both; `'pan'`
51
51
  // ⇒ drag only; `false`/`'none'` ⇒ neither. Zoom implies pan (there is no
@@ -414,7 +414,21 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
414
414
  // reads by **name** — `cursorFormat` has nothing to format, so the
415
415
  // readout channel stays unset.
416
416
  const cats = categories ?? [];
417
- const s = scaleBand(cats).domain([0, cats.length]).range([0, plotWidth]);
417
+ // [PND-BANDPACK] Cap the slot pitch, then place the resulting block. With
418
+ // no cap (or one too loose to bind) `packed === plotWidth` and `offset`
419
+ // is 0, so the range is `[0, plotWidth]` exactly as before — the whole
420
+ // feature collapses to the shipped behaviour when unused.
421
+ const n = cats.length;
422
+ const pitch = n > 0 ? plotWidth / n : plotWidth;
423
+ const capped = maxBandWidth !== undefined && maxBandWidth > 0
424
+ ? Math.min(pitch, maxBandWidth)
425
+ : pitch;
426
+ const packed = n > 0 ? capped * n : plotWidth;
427
+ const slack = Math.max(0, plotWidth - packed);
428
+ const offset = bandAlign === 'center' ? slack / 2 : bandAlign === 'end' ? slack : 0;
429
+ const s = scaleBand(cats)
430
+ .domain([0, n])
431
+ .range([offset, offset + packed]);
418
432
  return {
419
433
  xScale: s,
420
434
  formatTime: (v) => s.label(v),
@@ -555,6 +569,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
555
569
  }, [
556
570
  resolvedKind,
557
571
  categories,
572
+ maxBandWidth,
573
+ bandAlign,
558
574
  d0,
559
575
  d1,
560
576
  plotWidth,
package/dist/ChartRow.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
3
- import { scaleLinear } from 'd3-scale';
4
- import { resolveYDomain } from './domain.js';
2
+ import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ import { scaleLinear, scaleLog } from 'd3-scale';
4
+ import { isDev } from './dev.js';
5
+ import { logAxisWarning, needsExtents, resolveYDomain } from './domain.js';
5
6
  import { resolveAxisFormat } from './format.js';
6
7
  import { resolveYTickCount } from './yticks.js';
7
8
  import { placeAxisSlots } from './slots.js';
@@ -39,6 +40,7 @@ function axisSpecEqual(a, b) {
39
40
  return (a.id === b.id &&
40
41
  a.side === b.side &&
41
42
  a.width === b.width &&
43
+ a.scale === b.scale &&
42
44
  // Object.is (not ===) so a degenerate NaN bound compares equal to itself and
43
45
  // doesn't re-register every render.
44
46
  Object.is(a.min, b.min) &&
@@ -171,6 +173,7 @@ export function ChartRow({ height, cursor, children }) {
171
173
  id: IMPLICIT_AXIS_ID,
172
174
  side: 'left',
173
175
  width: 0,
176
+ scale: 'linear',
174
177
  min: undefined,
175
178
  max: undefined,
176
179
  pad: 0,
@@ -222,20 +225,68 @@ export function ChartRow({ height, cursor, children }) {
222
225
  const yScales = useMemo(() => {
223
226
  const map = new Map();
224
227
  for (const ax of effectiveAxes) {
225
- const extents = ax.min === undefined || ax.max === undefined
228
+ const extents = needsExtents(ax)
226
229
  ? layerList
227
230
  .filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
228
231
  .map((entry) => entry.layer.yExtent())
229
232
  : [];
230
- const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad);
233
+ const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad, ax.scale);
231
234
  // Reserve a header band at the top when any axis draws a `'top'` title,
232
235
  // so the title clears the top tick + plot (the whole row shifts down
233
236
  // uniformly, keeping stacked axes aligned). No top titles ⇒ range top 0,
234
237
  // so nothing changes for existing charts.
235
- map.set(ax.id, scaleLinear().domain([lo, hi]).range([height, topHeader]));
238
+ // `scaleLog` and `scaleLinear` share the call/ticks/tickFormat/invert
239
+ // surface every consumer uses (see `YScale`), so choosing between them
240
+ // here is the whole of log support — no draw layer branches on it.
241
+ const base = ax.scale === 'log' ? scaleLog() : scaleLinear();
242
+ map.set(ax.id, base.domain([lo, hi]).range([height, topHeader]));
236
243
  }
237
244
  return map;
238
245
  }, [effectiveAxes, layerList, height, defaultAxisId, topHeader]);
246
+ // Dev-mode diagnostics for a `scale="log"` axis (see `logAxisWarning`). Three
247
+ // things about *where* this sits are load-bearing, each of them a bug the
248
+ // first version shipped:
249
+ //
250
+ // - **An effect, not the scale memo.** Warning from inside `useMemo` is a
251
+ // side effect in a function React may call speculatively — and does call
252
+ // twice under StrictMode.
253
+ // - **Deduplicated by message, in a ref.** The comment on the original said
254
+ // "warn once per offending axis" and nothing implemented it, so a live
255
+ // chart re-warned on every appended sample. Keying on the message (not a
256
+ // bare "already warned" flag) still reports a *different* complaint if the
257
+ // data changes shape.
258
+ // - **`height` is not a dependency.** It is one for the scales, which is why
259
+ // the warning must not ride along: a drag-resize would otherwise emit a
260
+ // line per animation frame.
261
+ //
262
+ // Gated on `isDev` **and** on some axis actually being logarithmic, so a
263
+ // production build and every linear chart skip the extent walk entirely.
264
+ const warnedRef = useRef(new Map());
265
+ useEffect(() => {
266
+ if (!isDev || !effectiveAxes.some((ax) => ax.scale === 'log'))
267
+ return;
268
+ const warned = warnedRef.current;
269
+ for (const ax of effectiveAxes) {
270
+ // A linear axis in the same row has nothing to say and must not pay the
271
+ // O(points) walk below just because a sibling is logarithmic.
272
+ if (ax.scale !== 'log')
273
+ continue;
274
+ // Always walk the extents here, even for a fully-explicit domain the
275
+ // scale memo skips them for: data that cannot be drawn is worth saying so
276
+ // about whether or not it happened to constrain the bounds — and the
277
+ // both-explicit axis was exactly the case the first version stayed silent
278
+ // about.
279
+ const message = logAxisWarning(ax, layerList
280
+ .filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
281
+ .map((entry) => entry.layer.yExtent()));
282
+ if (message === null)
283
+ warned.delete(ax.id);
284
+ else if (warned.get(ax.id) !== message) {
285
+ warned.set(ax.id, message);
286
+ console.warn(message);
287
+ }
288
+ }
289
+ }, [effectiveAxes, layerList, defaultAxisId]);
239
290
  // Resolved auto-tick count per axis — explicit `<YAxis tickCount>` else
240
291
  // height-derived (see resolveYTickCount). The single source the `<YAxis>`
241
292
  // labels, the readout formatter (below), and the `Layers` gridlines all read,
package/dist/Layers.js CHANGED
@@ -5,6 +5,7 @@ import { drawGrid, drawDividers, dividerAlphas, thinPixels } from './grid.js';
5
5
  import { cursorParts, bandRect, regionSpan } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
+ import { yTickValues } from './yticks.js';
8
9
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
10
  import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
10
11
  /** Fallback **y**-gridline tick count, used only before the row publishes its
@@ -116,7 +117,7 @@ export function Layers({ children }) {
116
117
  // a gridline sits under every `<YAxis>` label and no more.
117
118
  const yCount = tickCounts.get(defaultAxisId) ?? GRID_TICKS;
118
119
  const yTicks = gridY && !(yIsCategory && explicitY === undefined)
119
- ? (explicitY ?? gridY.ticks(yCount)).map((t) => gridY(t))
120
+ ? (explicitY ?? yTickValues(gridY, yCount)).map((t) => gridY(t))
120
121
  : [];
121
122
  // On a calendar axis the verticals are the FULL grain populations —
122
123
  // every day in the month, every month in the year, every aligned
@@ -267,6 +268,18 @@ export function Layers({ children }) {
267
268
  // a full replot per mousemove.
268
269
  container.timeRange,
269
270
  container.reportDrawStats,
271
+ // Read inside `draw`, so they have to invalidate it. Omitting `grid` meant
272
+ // toggling `<ChartContainer grid>` changed nothing until some *other*
273
+ // dep moved — pan the plot a pixel and the gridlines you switched off
274
+ // finally vanished. All primitives, so no per-frame identity churn.
275
+ container.grid,
276
+ container.sessionDividers,
277
+ container.xKind,
278
+ // `container.theme` is read too. It is deliberately NOT listed: it is the
279
+ // caller's prop and an inline object literal would rebuild `draw` every
280
+ // render (a full replot per frame). The values `draw` actually takes from
281
+ // it — `background`, `gridColor`, `gridDash` — are extracted above and
282
+ // listed individually, so a theme swap still invalidates.
270
283
  row.rowKey,
271
284
  ]);
272
285
  // Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
package/dist/YAxis.d.ts CHANGED
@@ -20,6 +20,34 @@ export interface YAxisProps {
20
20
  * that has headroom (auto-fit / padded) so it doesn't crowd the top tick.
21
21
  */
22
22
  labelPlacement?: 'rotated' | 'top';
23
+ /**
24
+ * Which scale the axis maps its domain through. **Default `'linear'`.**
25
+ *
26
+ * `'log'` gives a base-10 logarithmic axis — for data spanning orders of
27
+ * magnitude, where a linear axis flattens everything below the top decade
28
+ * onto the baseline. Ticks land on the decades, and `format` still formats
29
+ * the **value**, so a readout says `1.2 PB`, not its logarithm.
30
+ *
31
+ * A log domain cannot contain zero or negative numbers — d3 maps them to
32
+ * `NaN`, which has no position on the plot. So:
33
+ *
34
+ * - **Auto-fit ignores non-positive extents** when picking the low end (a
35
+ * `BarChart`, whose extent always reaches zero so its bars can meet their
36
+ * baseline, can therefore share the axis), and rounds the domain out to
37
+ * whole powers of ten.
38
+ * - **An explicit `min`/`max` that is not positive is refused**, and that
39
+ * side auto-fits instead. A positive bound is always honoured exactly; when
40
+ * only one side is given and the domain would invert, the *auto* side moves
41
+ * — the same policy a linear axis follows.
42
+ * - **Layers that fill to a baseline** (`AreaChart`, `BarChart`, a stacked
43
+ * histogram) rest it on the bottom of the domain rather than on zero.
44
+ * - **A value with no position gaps the line**, rather than its neighbours
45
+ * being joined straight across it.
46
+ *
47
+ * A dev-mode warning fires for the cases that are unambiguously a mistake: a
48
+ * refused bound, negative data, or an axis with no positive data at all.
49
+ */
50
+ scale?: 'linear' | 'log';
23
51
  /** Explicit domain bounds; omit to auto-fit the charts linked to this axis. */
24
52
  min?: number;
25
53
  max?: number;
@@ -78,6 +106,33 @@ export interface YAxisProps {
78
106
  boundaryLabels?: boolean;
79
107
  /** Gutter width in CSS pixels (default 50). */
80
108
  width?: number;
109
+ /**
110
+ * **Keep the scale, draw no gutter.** The axis still registers its domain
111
+ * (`min`/`max`/`scale`/`pad`) and layers still bind to it by `id`, but it
112
+ * renders nothing and reserves **no width** — the plot gets the space.
113
+ *
114
+ * A `<YAxis>` does two jobs: it *holds the scale* and it *renders a gutter*.
115
+ * Without this there was no way to ask for the first without the second, so a
116
+ * chart with a **fixed** domain whose scale is already explained by its
117
+ * chrome (threshold band lines, a legend, a panel header) had two reachable
118
+ * options and needed a third:
119
+ *
120
+ * | | auto domain | explicit domain |
121
+ * |---|---|---|
122
+ * | **gutter** | `<YAxis />` | `<YAxis min max />` |
123
+ * | **no gutter** | omit the axis | ← this prop |
124
+ *
125
+ * Omitting the axis is not the same thing: the row then supplies an implicit
126
+ * auto-domain axis, and the fixed domain is exactly what must not be given
127
+ * up. `width={0}` is not it either — the labels still draw, now over the
128
+ * plot.
129
+ *
130
+ * **Gridlines are unaffected.** They belong to the plot, not the gutter, and
131
+ * `<ChartContainer grid>` already controls them — so a hidden axis can still rule
132
+ * its own gridlines, which is usually what a "the shape matters, the numbers
133
+ * don't" chart wants. Turn them off there if you want neither.
134
+ */
135
+ hide?: boolean;
81
136
  /**
82
137
  * This axis instance's colour — tick labels and the axis title take it,
83
138
  * overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
@@ -100,5 +155,5 @@ export interface YAxisProps {
100
155
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
101
156
  * (default: the first axis).
102
157
  */
103
- export declare function YAxis({ id, side, label, min, max, format, ticks, tickCount, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
158
+ export declare function YAxis({ id, side, label, scale, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
104
159
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -3,6 +3,7 @@ import { useContext, useEffect, useMemo } from 'react';
3
3
  import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
+ import { yTickValues } from './yticks.js';
6
7
  const DEFAULT_WIDTH = 50;
7
8
  /** Fallback tick count before the row has published its resolved count (the
8
9
  * first render, pre-registration). The row's height-derived value takes over
@@ -16,7 +17,7 @@ const DEFAULT_TICK_COUNT = 5;
16
17
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
17
18
  * (default: the first axis).
18
19
  */
19
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
20
+ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
20
21
  const container = useContext(ContainerContext);
21
22
  if (container === null) {
22
23
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -28,7 +29,12 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
28
29
  const spec = useMemo(() => ({
29
30
  id,
30
31
  side,
31
- width,
32
+ // A hidden axis reserves no gutter — the row's slot placement reads this
33
+ // width, so zeroing it here is what gives the space back to the plot.
34
+ // Everything else about the spec is unchanged: the domain still resolves
35
+ // and layers still bind to it, which is the whole point of the prop.
36
+ width: hide ? 0 : width,
37
+ scale,
32
38
  min,
33
39
  max,
34
40
  pad,
@@ -41,6 +47,8 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
41
47
  id,
42
48
  side,
43
49
  width,
50
+ hide,
51
+ scale,
44
52
  min,
45
53
  max,
46
54
  pad,
@@ -62,6 +70,23 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
62
70
  useEffect(() => {
63
71
  registerAxis(slot, spec);
64
72
  }, [registerAxis, slot, spec]);
73
+ // `hide`: everything above still runs — the axis is registered, so its scale
74
+ // exists and layers bind to it — and everything below (the gutter chrome)
75
+ // does not. Placed after the last hook so the early return can't change hook
76
+ // order when `hide` is toggled at runtime.
77
+ //
78
+ // It renders an **empty box at the reserved slot width**, not nothing. The
79
+ // container reserves each axis *column* at the widest across rows
80
+ // (`maxSlotWidths`), so a hidden axis sharing a column with a visible one in
81
+ // another row is still allotted that column's width — and drawing nothing
82
+ // there slides this row's plot left, out of line with its siblings and with
83
+ // the shared x-axis. When this axis is alone in its column the reservation is
84
+ // its own `width: 0`, the box is zero-wide, and the plot reclaims the space,
85
+ // which is the point of the prop. Both cases fall out of the same expression.
86
+ if (hide) {
87
+ const hiddenSlot = row.axisSlots.get(slot) ?? 0;
88
+ return hiddenSlot > 0 ? (_jsx("div", { "aria-hidden": "true", style: { flex: `0 0 ${hiddenSlot}px`, height: `${row.height}px` } })) : null;
89
+ }
65
90
  const { theme } = container;
66
91
  const yScale = row.yScales.get(id);
67
92
  // The auto-tick count — the row resolves it (explicit `tickCount` else
@@ -87,7 +112,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
87
112
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
88
113
  : layerCategories !== null
89
114
  ? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
90
- : (yScale ? yScale.ticks(count) : []).map((t) => ({
115
+ : (yScale ? yTickValues(yScale, count) : []).map((t) => ({
91
116
  value: t,
92
117
  label: fmt(t),
93
118
  }));
@@ -161,6 +161,80 @@ export interface BaselineProps {
161
161
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
162
162
  * Its label anchors at the left, at the line's height. */
163
163
  export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, role, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
164
+ export interface ZoneProps {
165
+ /** Lower bound in the linked y-axis's units. */
166
+ from: number;
167
+ /** Upper bound in the linked y-axis's units. `from`/`to` may arrive either way
168
+ * round (they're ordered here), and either may be **infinite** for an
169
+ * open-ended band (`to={Infinity}` — the AQI "Hazardous" tail, a
170
+ * `ZoneTime.openEnded` zone): the rect clamps to the plot. */
171
+ to: number;
172
+ /** Which `<YAxis>` (by id) to measure against; omit for the row's default axis. */
173
+ axis?: string;
174
+ /** Chip label, anchored at the band's vertical centre. **Omit for no label** —
175
+ * unlike `<Region>` a zone does *not* auto-label its bounds, because they're
176
+ * already legible on the y axis it spans (a region's x span isn't). The label
177
+ * worth showing is a **name** — `"Good"`, `"Z4 threshold"` — which only the
178
+ * caller has. */
179
+ label?: string;
180
+ /** Which side of the chart the label chip sits. **Default `left`.** */
181
+ labelSide?: 'left' | 'right';
182
+ /** Stable consumer id — a click reports it via `onSelectAnnotation`. Only
183
+ * meaningful with `selectable`. */
184
+ id?: string;
185
+ /** Controlled selection — brightens to the front (level 1). Ignored unless
186
+ * `selectable`. */
187
+ selected?: boolean;
188
+ /**
189
+ * Whether the band responds to hover + selection. **Default `false`** — the
190
+ * opposite of the rest of the family, and deliberately so: a zone spans the
191
+ * **full plot width**, and a zone *set* tiles the whole y range, so the pointer
192
+ * is always inside one. Interactive by default, they'd light up on every
193
+ * mousemove and their hit rects would swallow the plot's own clicks. A zone is
194
+ * background context first (level 3, pointer-transparent); opt in per band when
195
+ * a band is genuinely a thing to point at.
196
+ */
197
+ selectable?: boolean;
198
+ /** Theme **role** — colours this band from `theme.annotation.roles[role]` (its
199
+ * `color`, optionally `fillOpacity`), keeping the shared depth ramp. This is
200
+ * how a zone set gets its **semantic palette** (`good` green, `moderate`
201
+ * yellow, …): the scale lives in the theme, not at the call site. Omitted /
202
+ * unknown ⇒ the base annotation colour. */
203
+ role?: string;
204
+ /** Controlled hover (OR'd with pointer hover) — lets a legend row light the
205
+ * band remotely. Ignored unless `selectable`. */
206
+ hovered?: boolean;
207
+ /** Draw the horizontal **boundary lines** at `from`/`to`. **Default `false`** —
208
+ * again the opposite of `<Region>`, because zone sets are usually
209
+ * **contiguous**: every interior boundary is shared by two bands, so edges-on
210
+ * draws each one twice at double opacity. `true` outlines an isolated band (a
211
+ * target range). */
212
+ edges?: boolean;
213
+ }
214
+ /**
215
+ * A shaded band between two **y values**, spanning the full plot width — the
216
+ * y-axis counterpart of `<Region>`. The mark for a **classification of the value
217
+ * axis**: US EPA AQI categories, heart-rate / power zones, a control chart's
218
+ * spec limits, an SLO band.
219
+ *
220
+ * Being a `<Layers>` child it lives in its row and is scaled by that row's y
221
+ * axis — pass `axis` to pick one on a dual-axis row. Like every annotation it
222
+ * paints in the SVG overlay **above** the data canvas, so keep the fill light
223
+ * (the register's `fillOpacity`, ~0.1–0.2) and the trace reads cleanly through
224
+ * it. A zone set is a wash of colour behind the story, not a layer competing
225
+ * with it.
226
+ *
227
+ * Zones are **background context by default** (`selectable={false}`,
228
+ * `edges={false}`, no label) because that is what a tiled zone set is; see
229
+ * {@link ZoneProps.selectable} for why the family's usual defaults invert here.
230
+ * Colour comes from the theme's {@link ZoneProps.role | role} map, so a palette
231
+ * is a theme, not six call-site colours.
232
+ *
233
+ * Unlike the other marks a zone has **no `onChange`** — dragging zone edges
234
+ * (a zone editor) is a real feature but has no consumer yet; the band is
235
+ * declarative until one arrives.
236
+ */
237
+ export declare function Zone({ from, to, axis, label, labelSide, id, selected, selectable, hovered, role, edges, }: ZoneProps): import("react/jsx-runtime").JSX.Element | null;
164
238
  export interface RegionProps {
165
239
  /** Start x in axis units (time or value). */
166
240
  from: number;