@pond-ts/charts 0.45.0 → 0.47.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/XAxis.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useContext } from 'react';
3
+ import { scaleLinear } from 'd3-scale';
4
+ import { derivedTicks } from './derivedTicks.js';
3
5
  import { ContainerContext } from './context.js';
4
6
  import { axisPillStyle } from './chip.js';
5
7
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
@@ -7,8 +9,12 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
7
9
  const TICK_STRIP = 22;
8
10
  /** Extra height reserved for an axis `label` line. */
9
11
  const LABEL_STRIP = 16;
10
- /** Extra height reserved for the boundary (second) label row. */
11
- const BOUNDARY_STRIP = 15;
12
+ /** Extra height reserved for the stacked **band** (second) row. */
13
+ const BAND_STRIP = 20;
14
+ /** Minimum pixel gap between derived-unit (`transform`) ticks — the room a
15
+ * short numeric label needs plus breathing space, in the spirit of the
16
+ * ladder's per-tick budget (a hair tighter: derived labels are short). */
17
+ const TRANSFORM_TICK_PX = 48;
12
18
  /**
13
19
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
14
20
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -49,7 +55,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
49
55
  *
50
56
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
51
57
  */
52
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, align = 'center', } = {}) {
58
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', } = {}) {
53
59
  const container = useContext(ContainerContext);
54
60
  if (container === null) {
55
61
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -69,19 +75,89 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
69
75
  cursorX <= plotWidth;
70
76
  const cursorColor = theme.cursor ?? theme.axis.label;
71
77
  const annotationColor = theme.annotation?.color ?? '#0d9488';
72
- // Tick formatter: an explicit `format` is resolved against the axis kind
73
- // (a time specifier through the time scale, a number specifier through the
74
- // value scale); otherwise the container's shared formatter the one the
75
- // cursor readout uses, so a tick and the cursor read identically.
76
- const fmt =
77
- // A category axis labels by name (the container's `formatTime` = the band
78
- // scale's label lookup); a d3 number/time `format` can't name a category, so
79
- // it's ignored here (customize the labels in the `categories` data instead).
80
- format === undefined || xKind === 'category'
81
- ? formatTime
82
- : xKind === 'time'
83
- ? resolveTimeFormat(xScale, xTickCount, format)
84
- : resolveAxisFormat(xScale, xTickCount, format);
78
+ // Derived-unit (`transform`) layout: nice ticks in the derived unit at
79
+ // mixed step sizes, admitted where they keep pixel room (see derivedTicks).
80
+ // Explicit `ticks` win; a category axis has no numeric unit to derive from.
81
+ const derived = transform !== undefined && xKind !== 'category' && customTicks === undefined
82
+ ? derivedTicks(transform, xScale.domain(), (v) => xScale(v), plotWidth, TRANSFORM_TICK_PX)
83
+ : null;
84
+ // Formatter for derived-unit values `format` resolved against a u-space
85
+ // linear scale (so `'+.2f'` and the d3 number default both work).
86
+ const uFmt = transform !== undefined && xKind !== 'category'
87
+ ? (() => {
88
+ const [d0, d1] = xScale.domain();
89
+ const u = [transform.to(d0), transform.to(d1)].sort((a, b) => a - b);
90
+ return resolveAxisFormat(scaleLinear().domain(u), xTickCount, format);
91
+ })()
92
+ : null;
93
+ // Whether this axis draws **ladder-derived** date context (flat promotions or
94
+ // the stacked boundary row). Only a ladder-driven time scale supplies it;
95
+ // explicit `ticks`, an explicit axis `format`, and a container-level
96
+ // `timeFormat` all opt out (a custom format owns the whole label, and custom
97
+ // ticks have no grain).
98
+ const laddered = xKind === 'time' &&
99
+ customTicks === undefined &&
100
+ transform === undefined &&
101
+ format === undefined &&
102
+ !container.xFormatCustom &&
103
+ 'tickBoundaries' in xScale;
104
+ // The flat-style single-row label formatter — the coarsest calendar period
105
+ // each tick opens promoted inline, terse base labels otherwise (the
106
+ // TradingView default `dateStyle`). Replaces the shared `formatTime` for the
107
+ // tick labels; a non-tick instant (the cursor pill) still reads a full
108
+ // timestamp through it. `undefined` for the stacked style / a custom format.
109
+ const flatFmt = laddered && dateStyle === 'flat' && 'flatFormat' in xScale
110
+ ? xScale.flatFormat(xTickCount)
111
+ : undefined;
112
+ // The **stacked** date style renders a segmented **band** row (the coarser
113
+ // calendar period as zebra-shaded cells with left-aligned labels + dividers)
114
+ // beneath a terse top row, with the band-turn tick emphasized. The top row
115
+ // reads `baseFormat` (the grain's bare unit, no inline promotion — the
116
+ // context lives in the band), and `bands` are the segments.
117
+ const stacked = laddered && dateStyle === 'stacked' && 'bands' in xScale;
118
+ // The terse base label (the grain's bare unit, no promotion): the stacked
119
+ // top row, and — in flat — the yardstick for detecting which ticks were
120
+ // *promoted* to a coarser period, so those can be emphasized to match the
121
+ // stacked band turns (a period boundary reads the same in both styles).
122
+ const baseFmt = laddered && 'baseFormat' in xScale
123
+ ? xScale.baseFormat(xTickCount)
124
+ : undefined;
125
+ const bands = stacked ? xScale.bands(xTickCount) : [];
126
+ // Pixel positions where a band **divider** is drawn — the interior band
127
+ // starts (a start at/left of the plot edge maps to px ≤ 0 and draws no
128
+ // divider). A top-row tick landing on one is the band turn: it renders bold
129
+ // and in the divider colour, so tick + divider read as one continuous
130
+ // boundary line. Matched by **pixel**, not by instant, because on a trading
131
+ // axis the turn tick is the session OPEN sitting at the collapsed-midnight
132
+ // seam — the same pixel as the midnight band-start, but a different instant.
133
+ const dividerXs = new Set(bands
134
+ .map((b) => xScale(b.start))
135
+ .filter((px) => px > 0) // same threshold the band border uses (startPx > 0)
136
+ .map((px) => Math.round(px)));
137
+ // Tick / readout formatter: an explicit `format` is resolved against the axis
138
+ // kind (a time specifier through the time scale, a number specifier through
139
+ // the value scale); otherwise the container's shared formatter — the one the
140
+ // cursor readout uses, so a tick and the cursor read identically. On a
141
+ // transformed axis every readout (cursor pill, marker indicator) speaks the
142
+ // **derived unit** — the axis's own language. NOTE: the flat date style
143
+ // applies *only* to the rendered tick labels (`tickFmt` below), never to this
144
+ // `fmt` — the cursor pill and marker indicators keep reading a full timestamp
145
+ // rather than a terse/promoted axis label.
146
+ const fmt = transform !== undefined && uFmt !== null && xKind !== 'category'
147
+ ? (v) => uFmt(transform.to(v))
148
+ : // A category axis labels by name (the container's `formatTime` = the band
149
+ // scale's label lookup); a d3 number/time `format` can't name a category, so
150
+ // it's ignored here (customize the labels in the `categories` data instead).
151
+ format === undefined || xKind === 'category'
152
+ ? formatTime
153
+ : xKind === 'time'
154
+ ? resolveTimeFormat(xScale, xTickCount, format)
155
+ : resolveAxisFormat(xScale, xTickCount, format);
156
+ // The formatter for the rendered **tick labels** specifically: flat-style
157
+ // promoted labels, stacked-style terse base labels, else the shared `fmt`.
158
+ // Split from `fmt` so an axis's terse tick labels never leak into the cursor /
159
+ // marker readouts, which stay full timestamps.
160
+ const tickFmt = flatFmt ?? (stacked ? baseFmt : undefined) ?? fmt;
85
161
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
86
162
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
87
163
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -128,26 +204,53 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
128
204
  markerLanes.set(t.id, lane);
129
205
  }
130
206
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
131
- // The boundary (second) label row — the coarser calendar unit the first-row
132
- // label omits (the year under day / week / month ticks, the date under
133
- // clock ticks), placed under the first tick of each new
134
- // period. Only a ladder-driven time scale supplies it; explicit `ticks`, an
135
- // explicit axis `format`, and a container-level `timeFormat` all opt out (a
136
- // custom format owns the whole label, and custom ticks have no grain).
137
- const boundaryOf = xKind === 'time' &&
138
- customTicks === undefined &&
139
- format === undefined &&
140
- !container.xFormatCustom &&
141
- 'tickBoundaries' in xScale
142
- ? xScale.tickBoundaries(xTickCount)
143
- : undefined;
207
+ // (The `'stacked'` date style's coarser context is the segmented **band**
208
+ // row `bands`, computed above not a per-tick boundary label. The old
209
+ // ride-a-tick boundary row + pinned context are retired; the scale still
210
+ // exposes `tickBoundaries` / `boundaryContext` for any external consumer.)
211
+ // Derived ticks pass a **label-honesty filter**: the fill can descend below
212
+ // the format's resolution (a delta tick at u = 0.498 renders as "+0.50" under
213
+ // `+.2f` a lie about its position), so a tick survives only when its
214
+ // formatted label parses back to a value that maps to (±1px of) the tick's
215
+ // own pixel. This also caps density at the format's precision and drops
216
+ // would-be duplicate labels. Non-numeric labels (a custom format function)
217
+ // are trusted as-is.
218
+ const honestDerived = () => {
219
+ const out = [];
220
+ const seen = new Set();
221
+ for (const t of derived) {
222
+ const text = uFmt(t.u);
223
+ if (seen.has(text))
224
+ continue;
225
+ const back = parseFloat(text.replace(/\u2212/g, '-').replace(/,/g, ''));
226
+ if (Number.isFinite(back)) {
227
+ const bx = xScale(transform.from(back));
228
+ if (!Number.isFinite(bx) || Math.abs(bx - t.x) > 1)
229
+ continue;
230
+ }
231
+ seen.add(text);
232
+ out.push({ x: t.x, label: text });
233
+ }
234
+ return out;
235
+ };
144
236
  const rawTicks = customTicks
145
237
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
146
- : xScale.ticks(xTickCount).map((d) => ({
147
- x: xScale(d),
148
- label: fmt(+d),
149
- boundary: boundaryOf?.(+d),
150
- }));
238
+ : derived !== null
239
+ ? honestDerived()
240
+ : xScale.ticks(xTickCount).map((d) => ({
241
+ x: xScale(d),
242
+ label: tickFmt(+d),
243
+ // A **period turn** renders emphasized (bold), consistently across
244
+ // styles: in stacked, a tick on a band divider (matched by pixel);
245
+ // in flat, a tick whose label was *promoted* to a coarser period
246
+ // (its flat label differs from the terse base) — the same boundaries,
247
+ // so `Feb` / `2026` read as strong in flat just as the band turns do.
248
+ bold: stacked
249
+ ? dividerXs.has(Math.round(xScale(d)))
250
+ : flatFmt !== undefined &&
251
+ baseFmt !== undefined &&
252
+ flatFmt(+d) !== baseFmt(+d),
253
+ }));
151
254
  // A category axis ticks once per category; thin + truncate its labels when they
152
255
  // crowd (an explicit `customTicks` axis keeps its labels verbatim).
153
256
  const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
@@ -159,10 +262,19 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
159
262
  const pillOffset = align === 'right' ? 2 : 6;
160
263
  // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
161
264
  const PILL_LANE_H = theme.font.size + 6;
162
- // Any boundary label in view grows the strip by one row (like pill lanes do).
163
- const hasBoundary = placed.some((t) => t.boundary !== undefined);
265
+ // The stacked **band** row grows the strip by one band-height row (like pill
266
+ // lanes do). Only when the stacked style actually has bands (not at year
267
+ // grain / on a non-ladder axis).
268
+ const hasBands = stacked && bands.length > 0;
269
+ // Band-row colours (themeable via `theme.axis.band`): the zebra shade fill,
270
+ // the turn divider, and the label ink. A per-axis `color` override wins for
271
+ // divider + label (the fill stays the band's own shade).
272
+ const bandTheme = theme.axis.band;
273
+ const bandFill = bandTheme?.fill ?? theme.chip?.background ?? 'rgba(0,0,0,0.04)';
274
+ const bandDivider = color ?? bandTheme?.divider ?? theme.axis.grid;
275
+ const bandLabelColor = color ?? bandTheme?.label ?? theme.axis.title?.color ?? theme.axis.label;
164
276
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
165
- (hasBoundary ? BOUNDARY_STRIP : 0) +
277
+ (hasBands ? BAND_STRIP : 0) +
166
278
  maxPillLane * PILL_LANE_H;
167
279
  return (_jsxs("div", { style: {
168
280
  position: 'relative',
@@ -170,10 +282,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
170
282
  width: `${plotWidth}px`,
171
283
  height: `${stripHeight}px`,
172
284
  // The plot-facing edge carries the rule; a top axis rules its bottom.
173
- [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${theme.axis.grid}`,
285
+ [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${color ?? theme.axis.grid}`,
174
286
  fontFamily: theme.font.family,
175
287
  fontSize: `${theme.font.size}px`,
176
- color: theme.axis.label,
288
+ color: color ?? theme.axis.label,
177
289
  }, children: [placed.map((t, i) => {
178
290
  const isFirst = i === 0;
179
291
  const isLast = i === placed.length - 1;
@@ -187,8 +299,18 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
187
299
  : align === 'auto' && isLast
188
300
  ? 'translateX(-100%)'
189
301
  : 'translateX(-50%)';
190
- // `right` drops a longer tick alongside the label; others keep the 4px stub.
191
- const tickHeight = align === 'right' ? theme.font.size + 4 : 4;
302
+ // Stacked band mode: every tick runs the full tick-row height to *meet*
303
+ // the inter-row rule, so the boundary reads as one line continuing into
304
+ // the band divider below. `right` drops a longer tick beside the label;
305
+ // otherwise a 4px stub.
306
+ const tickHeight = hasBands
307
+ ? stripHeight - BAND_STRIP
308
+ : align === 'right'
309
+ ? theme.font.size + 4
310
+ : 4;
311
+ // The band-turn tick takes the divider colour so tick + divider read as
312
+ // one continuous boundary; minor ticks stay the faint grid colour.
313
+ const tickColor = hasBands && t.bold ? bandDivider : (color ?? theme.axis.grid);
192
314
  const labelLeft = align === 'right' ? t.x + 4 : t.x;
193
315
  const labelOffset = align === 'right' ? 2 : 6;
194
316
  return (_jsxs(Fragment, { children: [_jsx("div", { style: {
@@ -197,22 +319,55 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
197
319
  [onTop ? 'bottom' : 'top']: 0,
198
320
  width: '1px',
199
321
  height: `${tickHeight}px`,
200
- background: theme.axis.grid,
322
+ background: tickColor,
201
323
  } }), _jsx("div", { style: {
202
324
  position: 'absolute',
203
325
  left: `${labelLeft}px`,
204
326
  [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
205
327
  transform: labelTransform,
206
328
  whiteSpace: 'nowrap',
207
- }, children: t.label }), t.boundary !== undefined && (_jsx("div", { "data-boundary-label": true, style: {
208
- position: 'absolute',
209
- left: `${labelLeft}px`,
210
- [onTop ? 'bottom' : 'top']: `${labelOffset + theme.font.size + 3}px`,
211
- transform: labelTransform,
212
- whiteSpace: 'nowrap',
213
- opacity: 0.75,
214
- }, children: t.boundary }))] }, `${t.x}-${i}`));
215
- }), label !== undefined && (_jsx("div", { style: {
329
+ // Stacked band style bolds the tick sitting on a band turn
330
+ // (the day/month/year start) — the emphasized boundary tick.
331
+ fontWeight: t.bold ? 700 : undefined,
332
+ }, children: t.label })] }, `${t.x}-${i}`));
333
+ }), hasBands && (_jsx("div", { style: {
334
+ position: 'absolute',
335
+ left: 0,
336
+ width: `${plotWidth}px`,
337
+ [onTop ? 'top' : 'bottom']: 0,
338
+ height: `${BAND_STRIP}px`,
339
+ // The rule between the terse tick row and the band row.
340
+ [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${color ?? theme.axis.grid}`,
341
+ overflow: 'hidden',
342
+ }, children: bands.map((b, i) => {
343
+ // Each band spans [its start, the next band's start) in pixels,
344
+ // clamped to the plot. The first (partial) band starts off-screen
345
+ // left, so its label pins at x=0; interior bands carry a divider
346
+ // at their start. Shaded bands paint the zebra fill.
347
+ const startPx = xScale(b.start);
348
+ const left = Math.max(0, startPx);
349
+ const nextPx = i + 1 < bands.length ? xScale(bands[i + 1].start) : plotWidth;
350
+ const width = Math.max(0, Math.min(plotWidth, nextPx) - left);
351
+ if (width <= 0)
352
+ return null;
353
+ return (_jsx("div", { "data-band-label": true, style: {
354
+ position: 'absolute',
355
+ left: `${left}px`,
356
+ top: 0,
357
+ bottom: 0,
358
+ width: `${width}px`,
359
+ boxSizing: 'border-box',
360
+ background: b.shaded ? bandFill : 'transparent',
361
+ borderLeft: startPx > 0 ? `1px solid ${bandDivider}` : 'none',
362
+ display: 'flex',
363
+ alignItems: 'center',
364
+ paddingLeft: '8px',
365
+ color: bandLabelColor,
366
+ fontWeight: 600,
367
+ whiteSpace: 'nowrap',
368
+ overflow: 'hidden',
369
+ }, children: b.label }, b.start));
370
+ }) })), label !== undefined && (_jsx("div", { style: {
216
371
  position: 'absolute',
217
372
  left: 0,
218
373
  width: '100%',
@@ -220,7 +375,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
220
375
  [onTop ? 'top' : 'bottom']: 0,
221
376
  // Themeable axis-title text (shared with the rotated y-axis title).
222
377
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
223
- color: theme.axis.title?.color ?? theme.axis.label,
378
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
224
379
  opacity: theme.axis.title?.opacity ?? 0.85,
225
380
  whiteSpace: 'nowrap',
226
381
  }, children: label })), markerTags.map((t) => {
package/dist/YAxis.d.ts CHANGED
@@ -70,6 +70,14 @@ export interface YAxisProps {
70
70
  boundaryLabels?: boolean;
71
71
  /** Gutter width in CSS pixels (default 50). */
72
72
  width?: number;
73
+ /**
74
+ * This axis instance's colour — tick labels and the axis title take it,
75
+ * overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
76
+ * convention of colouring each y axis to match its series (`color`
77
+ * matching the layer's) — busy, but standard. Omit for the theme's axis
78
+ * colours. Presentation-only: it never re-registers the axis.
79
+ */
80
+ color?: string;
73
81
  /**
74
82
  * @internal Declaration position among the row's children, injected by
75
83
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -84,5 +92,5 @@ export interface YAxisProps {
84
92
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
85
93
  * (default: the first axis).
86
94
  */
87
- export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
95
+ export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
88
96
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -13,7 +13,7 @@ const TICK_COUNT = 5;
13
13
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
14
14
  * (default: the first axis).
15
15
  */
16
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', index = 0, }) {
16
+ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
17
17
  const container = useContext(ContainerContext);
18
18
  if (container === null) {
19
19
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -76,7 +76,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
76
76
  height: `${row.height}px`,
77
77
  fontFamily: theme.font.family,
78
78
  fontSize: `${theme.font.size}px`,
79
- color: theme.axis.label,
79
+ color: color ?? theme.axis.label,
80
80
  }, children: [yScale &&
81
81
  tickList.map(({ value, label }, i) => {
82
82
  // Drop just the top & bottom labels when boundary labels are off
@@ -102,7 +102,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
102
102
  // labels' alignment, rather than floating at the outer gutter edge.
103
103
  [side === 'left' ? 'right' : 'left']: '4px',
104
104
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
105
- color: theme.axis.title?.color ?? theme.axis.label,
105
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
106
106
  opacity: theme.axis.title?.opacity ?? 0.85,
107
107
  whiteSpace: 'nowrap',
108
108
  pointerEvents: 'none',
@@ -116,7 +116,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
116
116
  alignItems: 'center',
117
117
  justifyContent: 'center',
118
118
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
119
- color: theme.axis.title?.color ?? theme.axis.label,
119
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
120
120
  opacity: theme.axis.title?.opacity ?? 0.85,
121
121
  pointerEvents: 'none',
122
122
  }, children: _jsx("span", { style: {
@@ -15,7 +15,7 @@ import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from '.
15
15
  * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
16
16
  * label) so the static labels hold their lanes as it crosses them.
17
17
  */
18
- export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
18
+ export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null, plotWidth?: number): Map<symbol, LabelPlacement>;
19
19
  /**
20
20
  * Snap a dragged plot-pixel `px` to the nearest **guideline** within
21
21
  * {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
@@ -169,7 +169,7 @@ const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
169
169
  * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
170
170
  * label) so the static labels hold their lanes as it crosses them.
171
171
  */
172
- export function computeLabelLanes(annotations, toPixel, draggingKey) {
172
+ export function computeLabelLanes(annotations, toPixel, draggingKey, plotWidth) {
173
173
  const out = new Map();
174
174
  const byRow = new Map();
175
175
  for (const a of annotations) {
@@ -195,22 +195,33 @@ export function computeLabelLanes(annotations, toPixel, draggingKey) {
195
195
  markerGroups.set(a.xs[0], [a]);
196
196
  }
197
197
  else {
198
+ // Lane-pack at the position the chip will *render*: a region panned
199
+ // half off-plot renders clamped to the plot's left edge, and a fully
200
+ // off-plot region's chip is culled — so it must not hold a lane.
198
201
  const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
202
+ const bx = a.kind === 'region' ? Math.max(a.xs[0], a.xs[1]) : a.xs[0];
203
+ const rawLeft = toPixel(ax);
204
+ if (plotWidth !== undefined && (rawLeft > plotWidth || toPixel(bx) < 0))
205
+ continue;
199
206
  flags.push({
200
207
  rep: a.key,
201
208
  members: [a.key],
202
- left: toPixel(ax),
209
+ left: plotWidth === undefined ? rawLeft : Math.max(rawLeft, 0),
203
210
  width: labelWidth(a.label),
204
211
  label: a.label,
205
212
  });
206
213
  }
207
214
  }
208
215
  for (const [x, group] of markerGroups) {
216
+ // A culled off-plot marker chip must not hold a lane either.
217
+ const px = toPixel(x);
218
+ if (plotWidth !== undefined && (px < 0 || px > plotWidth))
219
+ continue;
209
220
  const label = group.map((g) => g.label).join(', ');
210
221
  flags.push({
211
222
  rep: group[0].key,
212
223
  members: group.map((g) => g.key),
213
- left: toPixel(x),
224
+ left: px,
214
225
  width: labelWidth(label),
215
226
  label,
216
227
  });
@@ -473,7 +484,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
473
484
  // into a lower lane doesn't leave line poking above it. No label ⇒ full height.
474
485
  const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
475
486
  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) ??
476
- +container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
487
+ +container.xScale.invert(px)) }))] }), chipLabel && x >= 0 && x <= container.plotWidth && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
477
488
  top: `${FLAG_TOP + lane * LANE_H}px`,
478
489
  ...flagChipX(x, container.plotWidth),
479
490
  }, children: chipLabel }))] }));
@@ -618,9 +629,9 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
618
629
  +container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - 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: () => {
619
630
  edgeRef.current = from; // the fixed pivot = the near edge
620
631
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
621
- +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
632
+ +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && left <= container.plotWidth && left + spanW >= 0 && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
622
633
  top: `${FLAG_TOP + lane * LANE_H}px`,
623
- ...flagChipX(left, container.plotWidth),
634
+ ...flagChipX(Math.max(left, 0), container.plotWidth),
624
635
  }, children: text }))] }));
625
636
  }
626
637
  //# sourceMappingURL=annotations.js.map
package/dist/context.d.ts CHANGED
@@ -203,6 +203,14 @@ export interface ContainerFrame {
203
203
  * rather than raw wall-clock ms.
204
204
  */
205
205
  readonly discontinuities?: DiscontinuityProvider | undefined;
206
+ /** Draw the reference gridlines behind the data (default `true`; the
207
+ * container's `grid` prop). Session dividers are independent of this. */
208
+ readonly grid: boolean;
209
+ /** Where session dividers draw on a trading axis: `'none'` (the default —
210
+ * the hierarchical grid already marks calendar structure), `'all'` (every
211
+ * session boundary in view — the TradingView separator look), or
212
+ * `'labeled'` (only under labelled collapse points). */
213
+ readonly sessionDividers: 'labeled' | 'all' | 'none';
206
214
  /**
207
215
  * The resolved kind of the shared x scale — `'time'` (a `scaleTime`),
208
216
  * `'value'` (a `scaleLinear`), or `'category'` (a {@link ScaleBand}: an ordinal
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tick layout for a **derived-unit axis** — a second labeling of the same
3
+ * scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
4
+ * relabelled as BS delta. The transform may be nonlinear, which stretches or
5
+ * compresses the derived unit across the pixel range — so a single uniform
6
+ * step can't work (uniform delta ticks pile up mid-axis and leave the
7
+ * stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
8
+ * walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
9
+ * candidate tick wherever it keeps `minPx` of room from every tick already
10
+ * placed. A compressed span ends up with coarse ticks, a stretched span picks
11
+ * up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
12
+ * 0.49` out in the wings). A linear transform degenerates to ordinary
13
+ * evenly-spaced nice ticks through the same code path.
14
+ */
15
+ /** A derived-unit transform: `to`/`from` are monotonic inverses (either
16
+ * direction — a decreasing transform is fine); they may be nonlinear. */
17
+ export interface AxisTransform {
18
+ /** Axis value → derived unit (e.g. strike → moneyness). */
19
+ to(value: number): number;
20
+ /** Derived unit → axis value (inverse of {@link to}). */
21
+ from(unit: number): number;
22
+ }
23
+ /** One derived tick: its value in the derived unit and its plot pixel. */
24
+ export interface DerivedTick {
25
+ readonly u: number;
26
+ readonly x: number;
27
+ }
28
+ /**
29
+ * Compute the derived-unit ticks: nice values in `transform.to`-space at
30
+ * mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
31
+ * mapped pixel keeps `minPx` from every tick already placed (and stays inside
32
+ * `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
33
+ * without a DOM.
34
+ */
35
+ export declare function derivedTicks(transform: AxisTransform, domain: readonly [number, number], toPixel: (value: number) => number, plotWidth: number, minPx: number): DerivedTick[];
36
+ //# sourceMappingURL=derivedTicks.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tick layout for a **derived-unit axis** — a second labeling of the same
3
+ * scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
4
+ * relabelled as BS delta. The transform may be nonlinear, which stretches or
5
+ * compresses the derived unit across the pixel range — so a single uniform
6
+ * step can't work (uniform delta ticks pile up mid-axis and leave the
7
+ * stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
8
+ * walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
9
+ * candidate tick wherever it keeps `minPx` of room from every tick already
10
+ * placed. A compressed span ends up with coarse ticks, a stretched span picks
11
+ * up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
12
+ * 0.49` out in the wings). A linear transform degenerates to ordinary
13
+ * evenly-spaced nice ticks through the same code path.
14
+ */
15
+ /** The largest 1-2-5 nice step ≤ `span` (so the coarsest level yields at
16
+ * least one interval across the domain). */
17
+ function firstStep(span) {
18
+ const pow = 10 ** Math.floor(Math.log10(span));
19
+ for (const m of [5, 2, 1]) {
20
+ if (m * pow <= span)
21
+ return m * pow;
22
+ }
23
+ return pow / 2; // span < pow can't happen (pow ≤ span), belt-and-braces
24
+ }
25
+ /** The next step down the 1-2-5 ladder: 5→2→1→0.5→0.2→0.1… */
26
+ function nextFiner(step) {
27
+ const pow = 10 ** Math.floor(Math.log10(step));
28
+ const m = Math.round(step / pow);
29
+ if (m === 5)
30
+ return 2 * pow;
31
+ if (m === 2)
32
+ return pow;
33
+ return pow / 2;
34
+ }
35
+ /** Per-level enumeration cap — a backstop against a pathological transform
36
+ * requesting a step so fine the candidate walk explodes. Generous: the
37
+ * reference delta axis enumerates ~100 candidates at its finest level. */
38
+ const MAX_CANDIDATES = 4000;
39
+ /**
40
+ * Compute the derived-unit ticks: nice values in `transform.to`-space at
41
+ * mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
42
+ * mapped pixel keeps `minPx` from every tick already placed (and stays inside
43
+ * `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
44
+ * without a DOM.
45
+ */
46
+ export function derivedTicks(transform, domain, toPixel, plotWidth, minPx) {
47
+ const ua = transform.to(domain[0]);
48
+ const ub = transform.to(domain[1]);
49
+ const u0 = Math.min(ua, ub);
50
+ const u1 = Math.max(ua, ub);
51
+ if (!Number.isFinite(u0) ||
52
+ !Number.isFinite(u1) ||
53
+ u1 <= u0 ||
54
+ !(plotWidth > 0)) {
55
+ return [];
56
+ }
57
+ const kept = [];
58
+ const fits = (x) => x >= 0 && x <= plotWidth && kept.every((k) => Math.abs(k.x - x) >= minPx);
59
+ // More ticks than the plot has room for can never be admitted.
60
+ const maxTicks = Math.ceil(plotWidth / minPx) + 2;
61
+ let step = firstStep(u1 - u0);
62
+ // An empty level does NOT end the walk: under a nonlinear transform a
63
+ // pixel-wide gap can have a tiny u-span (the delta wings — no 0.1-grid
64
+ // value lands in [0.4, 0.4987], but 0.45 on the 0.05 grid does), so finer
65
+ // levels may fill where a coarser one placed nothing. Several *consecutive*
66
+ // empty levels mean the remaining gaps' u-spans are being outrun faster
67
+ // than the ladder descends — give up then (plus the enumeration backstop).
68
+ let emptyLevels = 0;
69
+ for (let level = 0; level < 24 && kept.length < maxTicks; level++) {
70
+ const i0 = Math.ceil(u0 / step - 1e-9);
71
+ const i1 = Math.floor(u1 / step + 1e-9);
72
+ if (i1 - i0 > MAX_CANDIDATES)
73
+ break;
74
+ let added = 0;
75
+ for (let i = i0; i <= i1 && kept.length < maxTicks; i++) {
76
+ // Clean the float (0.3, not 0.30000000000000004) — the raw `u` reaches
77
+ // a caller-supplied format function, so it must be presentable.
78
+ const u = Number((i * step).toPrecision(12));
79
+ const x = toPixel(transform.from(u));
80
+ if (Number.isFinite(x) && fits(x)) {
81
+ kept.push({ u, x });
82
+ added += 1;
83
+ }
84
+ }
85
+ emptyLevels = added === 0 && level > 0 ? emptyLevels + 1 : 0;
86
+ if (emptyLevels >= 3)
87
+ break;
88
+ step = nextFiner(step);
89
+ }
90
+ return kept.sort((a, b) => a.x - b.x);
91
+ }
92
+ //# sourceMappingURL=derivedTicks.js.map