@pond-ts/charts 0.46.0 → 0.48.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
@@ -9,8 +9,8 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
9
9
  const TICK_STRIP = 22;
10
10
  /** Extra height reserved for an axis `label` line. */
11
11
  const LABEL_STRIP = 16;
12
- /** Extra height reserved for the boundary (second) label row. */
13
- const BOUNDARY_STRIP = 15;
12
+ /** Extra height reserved for the stacked **band** (second) row. */
13
+ const BAND_STRIP = 20;
14
14
  /** Minimum pixel gap between derived-unit (`transform`) ticks — the room a
15
15
  * short numeric label needs plus breathing space, in the spirit of the
16
16
  * ladder's per-tick budget (a hair tighter: derived labels are short). */
@@ -55,7 +55,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
55
55
  *
56
56
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
57
57
  */
58
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', } = {}) {
58
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', } = {}) {
59
59
  const container = useContext(ContainerContext);
60
60
  if (container === null) {
61
61
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -90,12 +90,59 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
90
90
  return resolveAxisFormat(scaleLinear().domain(u), xTickCount, format);
91
91
  })()
92
92
  : null;
93
- // Tick formatter: an explicit `format` is resolved against the axis kind
94
- // (a time specifier through the time scale, a number specifier through the
95
- // value scale); otherwise the container's shared formatter the one the
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
96
140
  // cursor readout uses, so a tick and the cursor read identically. On a
97
141
  // transformed axis every readout (cursor pill, marker indicator) speaks the
98
- // **derived unit** — the axis's own language.
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.
99
146
  const fmt = transform !== undefined && uFmt !== null && xKind !== 'category'
100
147
  ? (v) => uFmt(transform.to(v))
101
148
  : // A category axis labels by name (the container's `formatTime` = the band
@@ -106,6 +153,11 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
106
153
  : xKind === 'time'
107
154
  ? resolveTimeFormat(xScale, xTickCount, format)
108
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;
109
161
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
110
162
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
111
163
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -152,28 +204,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
152
204
  markerLanes.set(t.id, lane);
153
205
  }
154
206
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
155
- // The boundary (second) label row — the coarser calendar unit the first-row
156
- // label omits (the year under day / week / month ticks, the date under
157
- // clock ticks), placed under the first tick of each new
158
- // period. Only a ladder-driven time scale supplies it; explicit `ticks`, an
159
- // explicit axis `format`, and a container-level `timeFormat` all opt out (a
160
- // custom format owns the whole label, and custom ticks have no grain).
161
- const boundaryOf = xKind === 'time' &&
162
- customTicks === undefined &&
163
- transform === undefined &&
164
- format === undefined &&
165
- !container.xFormatCustom &&
166
- 'tickBoundaries' in xScale
167
- ? xScale.tickBoundaries(xTickCount)
168
- : undefined;
169
- // The pinned left-edge **context** label — what period the domain starts in
170
- // (`Jan 01` over an intraday axis, the year over a month axis). A property
171
- // of the domain, not of any tick: anchoring it to the first tick made it
172
- // hop tick-to-tick on a live sliding window. Crossing labels ride their
173
- // ticks and **push it off** the left edge as they approach (below).
174
- const boundaryContext = boundaryOf !== undefined && 'boundaryContext' in xScale
175
- ? xScale.boundaryContext(xTickCount)
176
- : 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.)
177
211
  // Derived ticks pass a **label-honesty filter**: the fill can descend below
178
212
  // the format's resolution (a delta tick at u = 0.498 renders as "+0.50" under
179
213
  // `+.2f` — a lie about its position), so a tick survives only when its
@@ -205,8 +239,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
205
239
  ? honestDerived()
206
240
  : xScale.ticks(xTickCount).map((d) => ({
207
241
  x: xScale(d),
208
- label: fmt(+d),
209
- boundary: boundaryOf?.(+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),
210
253
  }));
211
254
  // A category axis ticks once per category; thin + truncate its labels when they
212
255
  // crowd (an explicit `customTicks` axis keeps its labels verbatim).
@@ -219,34 +262,19 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
219
262
  const pillOffset = align === 'right' ? 2 : 6;
220
263
  // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
221
264
  const PILL_LANE_H = theme.font.size + 6;
222
- // Any boundary label in view grows the strip by one row (like pill lanes do).
223
- const hasBoundary = boundaryContext !== undefined ||
224
- placed.some((t) => t.boundary !== undefined);
225
- // The pinned context anchors at the plot's left edge (the y-axis line) with
226
- // the SAME alignment as the tick labels — centred on it in `center` mode
227
- // (exactly where a first-tick label at x=0 sat, half into the gutter by the
228
- // same documented rule), left-anchored in `auto`, beside the line in
229
- // `right`. As the leftmost crossing label slides toward the edge it would
230
- // collide, so the context CULLS once the crossing label's left edge reaches
231
- // the context's right edge (plus a gap) — no overlap, and nothing slides
232
- // loose into the gutter. Widths from the same rough glyph metric the pills
233
- // use; the crossing's label is centred on its tick.
234
- const charW = theme.font.size * 0.62;
235
- const contextWidth = (boundaryContext?.length ?? 0) * charW;
236
- const contextRight = align === 'center'
237
- ? contextWidth / 2
238
- : align === 'right'
239
- ? 4 + contextWidth
240
- : contextWidth;
241
- const firstCrossing = placed.find((t) => t.boundary !== undefined);
242
- const crossingLeft = firstCrossing
243
- ? align === 'right'
244
- ? firstCrossing.x + 4
245
- : firstCrossing.x - (firstCrossing.boundary.length * charW) / 2
246
- : Infinity;
247
- const showContext = boundaryContext !== undefined && crossingLeft > contextRight + 6;
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;
248
276
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
249
- (hasBoundary ? BOUNDARY_STRIP : 0) +
277
+ (hasBands ? BAND_STRIP : 0) +
250
278
  maxPillLane * PILL_LANE_H;
251
279
  return (_jsxs("div", { style: {
252
280
  position: 'relative',
@@ -271,8 +299,18 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
271
299
  : align === 'auto' && isLast
272
300
  ? 'translateX(-100%)'
273
301
  : 'translateX(-50%)';
274
- // `right` drops a longer tick alongside the label; others keep the 4px stub.
275
- 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);
276
314
  const labelLeft = align === 'right' ? t.x + 4 : t.x;
277
315
  const labelOffset = align === 'right' ? 2 : 6;
278
316
  return (_jsxs(Fragment, { children: [_jsx("div", { style: {
@@ -281,29 +319,55 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
281
319
  [onTop ? 'bottom' : 'top']: 0,
282
320
  width: '1px',
283
321
  height: `${tickHeight}px`,
284
- background: color ?? theme.axis.grid,
322
+ background: tickColor,
285
323
  } }), _jsx("div", { style: {
286
324
  position: 'absolute',
287
325
  left: `${labelLeft}px`,
288
326
  [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
289
327
  transform: labelTransform,
290
328
  whiteSpace: 'nowrap',
291
- }, children: t.label }), t.boundary !== undefined && (_jsx("div", { "data-boundary-label": true, style: {
292
- position: 'absolute',
293
- left: `${labelLeft}px`,
294
- [onTop ? 'bottom' : 'top']: `${labelOffset + theme.font.size + 3}px`,
295
- transform: labelTransform,
296
- whiteSpace: 'nowrap',
297
- opacity: 0.75,
298
- }, children: t.boundary }))] }, `${t.x}-${i}`));
299
- }), showContext && (_jsx("div", { "data-boundary-label": true, "data-boundary-context": true, 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: {
300
334
  position: 'absolute',
301
- left: `${align === 'right' ? 4 : 0}px`,
302
- [onTop ? 'bottom' : 'top']: `${(align === 'right' ? 2 : 6) + theme.font.size + 3}px`,
303
- transform: align === 'center' ? 'translateX(-50%)' : 'none',
304
- whiteSpace: 'nowrap',
305
- opacity: 0.75,
306
- }, children: boundaryContext })), label !== undefined && (_jsx("div", { style: {
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: {
307
371
  position: 'absolute',
308
372
  left: 0,
309
373
  width: '100%',
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
package/dist/format.d.ts CHANGED
@@ -6,12 +6,32 @@
6
6
  * uses for the ticks), a **function** is used verbatim, and `undefined` falls
7
7
  * back to the scale's default `tickFormat`.
8
8
  */
9
+ import type { TimeGrain } from './tickLadder.js';
9
10
  /**
10
11
  * How to format an axis's values — a d3 [format specifier]
11
12
  * (https://github.com/d3/d3-format#locale_format) string, or a custom
12
13
  * `(value) => string` function. Omit for the scale's d3 default.
13
14
  */
14
15
  export type AxisFormat = string | ((value: number) => string);
16
+ /**
17
+ * How to format the **cursor / marker readout** on a time axis
18
+ * ({@link ChartContainerProps.cursorFormat}). Either:
19
+ *
20
+ * - a d3 time specifier **string** (e.g. `'%b %-d'`) applied uniformly at every
21
+ * zoom; or
22
+ * - a **function** `(epochMs, ctx) => string`, where `ctx.grain` is the axis's
23
+ * resolved coarse {@link TimeGrain} (`year` … `second`) and `ctx.defaultText`
24
+ * is the library's grain-aware default readout for that instant — so a
25
+ * consumer can branch on the zoom level (`grain === 'year' ? … : …`) and
26
+ * pass `defaultText` through for the grains they don't want to override.
27
+ *
28
+ * The library hands you the grain because it already resolved it — you never
29
+ * re-derive it from the range.
30
+ */
31
+ export type CursorFormat = string | ((epochMs: number, ctx: {
32
+ readonly grain: TimeGrain;
33
+ readonly defaultText: string;
34
+ }) => string);
15
35
  /** The slice of a d3 scale {@link resolveAxisFormat} needs — `tickFormat` with an
16
36
  * optional specifier. A d3 `ScaleLinear` / `ScaleTime` satisfies it. */
17
37
  interface Tickable {
package/dist/grid.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Stroke the plot's gridlines: a vertical line at each `xTicks` pixel and a
3
- * horizontal line at each `yTicks` pixel, faint and dashed. Drawn behind the
4
- * data layers from the same tick positions the axes label, so grid and labels
5
- * line up. `+0.5` aligns each 1px stroke to the device grid for a crisp line.
3
+ * horizontal line at each `yTicks` pixel, faint and dashed. On a calendar
4
+ * axis the verticals are the **full grain populations** (every day / month /
5
+ * aligned hour in view see `TradingTimeScale.gridLevels`), not just the
6
+ * labelled ticks. `+0.5` aligns each 1px stroke to the device grid for a
7
+ * crisp line.
8
+ *
9
+ * `xAlphas` (parallel to `xTicks`) fades individual verticals — the
10
+ * hierarchical density falloff: a crowding grain's lines dim toward invisible
11
+ * while coarser grains hold full strength. Full-alpha lines (and all the
12
+ * horizontals) batch into one path; only the fading remainder pays a
13
+ * per-line stroke. A near-zero alpha is skipped.
6
14
  *
7
15
  * `save`/`restore` brackets the dash + stroke state so it doesn't leak into the
8
16
  * data layers that draw next.
9
17
  */
10
- export declare function drawGrid(ctx: CanvasRenderingContext2D, xTicks: readonly number[], yTicks: readonly number[], width: number, height: number, color: string, dash: readonly number[]): void;
18
+ export declare function drawGrid(ctx: CanvasRenderingContext2D, xTicks: readonly number[], yTicks: readonly number[], width: number, height: number, color: string, dash: readonly number[], xAlphas?: readonly number[]): void;
11
19
  /**
12
20
  * Greedily thin an **ascending** list of pixel positions so no two kept lines
13
21
  * are closer than `minGap` px — keeps the axis from crowding when collapse
@@ -20,6 +28,25 @@ export declare function thinPixels(xs: readonly number[], minGap: number): numbe
20
28
  * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
21
29
  * session/day boundary reads as structural, not just another tick) at the
22
30
  * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
31
+ *
32
+ * `alphas` (parallel to `xs`) fades individual lines — used by the `'all'`
33
+ * session-divider mode so crowding lines dim smoothly toward invisible instead
34
+ * of popping in/out with a hard density cutoff. Omitted ⇒ every line at full
35
+ * opacity in a single path (the fast default). A near-zero alpha is skipped.
36
+ */
37
+ export declare function drawDividers(ctx: CanvasRenderingContext2D, xs: readonly number[], height: number, color: string, alphas?: readonly number[]): void;
38
+ /**
39
+ * Per-line opacity for {@link drawDividers} in `'all'` mode: each line keys off
40
+ * the gap to its nearest neighbour — full at `fullPx`+, **zero** at `gonePx`
41
+ * and below, a quadratic ramp between. So as a zoom-out crowds the session
42
+ * lines they dim toward a clean plot — no hard drop that pops on pan.
43
+ *
44
+ * The curve must fall **superlinearly** in the gap: the veil a reader sees is
45
+ * `alpha × density = alpha / gap`, so the earlier linear ramp (`alpha = gap/f`)
46
+ * cancelled the density growth exactly and pinned a constant gray wash over the
47
+ * whole plot no matter how far out you zoomed. Quadratic-to-a-floor makes the
48
+ * wash itself → 0 as lines converge: alpha `t²` with
49
+ * `t = (gap − gonePx) / (fullPx − gonePx)`. `xs` ascending.
23
50
  */
24
- export declare function drawDividers(ctx: CanvasRenderingContext2D, xs: readonly number[], height: number, color: string): void;
51
+ export declare function dividerAlphas(xs: readonly number[], gonePx: number, fullPx: number): number[];
25
52
  //# sourceMappingURL=grid.d.ts.map
package/dist/grid.js CHANGED
@@ -1,20 +1,30 @@
1
1
  /**
2
2
  * Stroke the plot's gridlines: a vertical line at each `xTicks` pixel and a
3
- * horizontal line at each `yTicks` pixel, faint and dashed. Drawn behind the
4
- * data layers from the same tick positions the axes label, so grid and labels
5
- * line up. `+0.5` aligns each 1px stroke to the device grid for a crisp line.
3
+ * horizontal line at each `yTicks` pixel, faint and dashed. On a calendar
4
+ * axis the verticals are the **full grain populations** (every day / month /
5
+ * aligned hour in view see `TradingTimeScale.gridLevels`), not just the
6
+ * labelled ticks. `+0.5` aligns each 1px stroke to the device grid for a
7
+ * crisp line.
8
+ *
9
+ * `xAlphas` (parallel to `xTicks`) fades individual verticals — the
10
+ * hierarchical density falloff: a crowding grain's lines dim toward invisible
11
+ * while coarser grains hold full strength. Full-alpha lines (and all the
12
+ * horizontals) batch into one path; only the fading remainder pays a
13
+ * per-line stroke. A near-zero alpha is skipped.
6
14
  *
7
15
  * `save`/`restore` brackets the dash + stroke state so it doesn't leak into the
8
16
  * data layers that draw next.
9
17
  */
10
- export function drawGrid(ctx, xTicks, yTicks, width, height, color, dash) {
18
+ export function drawGrid(ctx, xTicks, yTicks, width, height, color, dash, xAlphas) {
11
19
  ctx.save();
12
20
  ctx.strokeStyle = color;
13
21
  ctx.lineWidth = 1;
14
22
  ctx.setLineDash([...dash]);
15
23
  ctx.beginPath();
16
- for (const x of xTicks) {
17
- const px = Math.round(x) + 0.5;
24
+ for (let i = 0; i < xTicks.length; i++) {
25
+ if (xAlphas !== undefined && (xAlphas[i] ?? 1) < 1)
26
+ continue;
27
+ const px = Math.round(xTicks[i]) + 0.5;
18
28
  ctx.moveTo(px, 0);
19
29
  ctx.lineTo(px, height);
20
30
  }
@@ -24,6 +34,19 @@ export function drawGrid(ctx, xTicks, yTicks, width, height, color, dash) {
24
34
  ctx.lineTo(width, py);
25
35
  }
26
36
  ctx.stroke();
37
+ if (xAlphas !== undefined) {
38
+ for (let i = 0; i < xTicks.length; i++) {
39
+ const a = xAlphas[i] ?? 1;
40
+ if (a >= 1 || a <= 0.02)
41
+ continue;
42
+ ctx.globalAlpha = a;
43
+ const px = Math.round(xTicks[i]) + 0.5;
44
+ ctx.beginPath();
45
+ ctx.moveTo(px, 0);
46
+ ctx.lineTo(px, height);
47
+ ctx.stroke();
48
+ }
49
+ }
27
50
  ctx.restore();
28
51
  }
29
52
  /**
@@ -45,21 +68,66 @@ export function thinPixels(xs, minGap) {
45
68
  * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
46
69
  * session/day boundary reads as structural, not just another tick) at the
47
70
  * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
71
+ *
72
+ * `alphas` (parallel to `xs`) fades individual lines — used by the `'all'`
73
+ * session-divider mode so crowding lines dim smoothly toward invisible instead
74
+ * of popping in/out with a hard density cutoff. Omitted ⇒ every line at full
75
+ * opacity in a single path (the fast default). A near-zero alpha is skipped.
48
76
  */
49
- export function drawDividers(ctx, xs, height, color) {
77
+ export function drawDividers(ctx, xs, height, color, alphas) {
50
78
  if (xs.length === 0)
51
79
  return;
52
80
  ctx.save();
53
81
  ctx.strokeStyle = color;
54
82
  ctx.lineWidth = 1;
55
83
  ctx.setLineDash([]);
56
- ctx.beginPath();
57
- for (const x of xs) {
58
- const px = Math.round(x) + 0.5;
59
- ctx.moveTo(px, 0);
60
- ctx.lineTo(px, height);
84
+ if (alphas === undefined) {
85
+ ctx.beginPath();
86
+ for (const x of xs) {
87
+ const px = Math.round(x) + 0.5;
88
+ ctx.moveTo(px, 0);
89
+ ctx.lineTo(px, height);
90
+ }
91
+ ctx.stroke();
92
+ }
93
+ else {
94
+ // Per-line opacity → each line is its own path (globalAlpha can't vary
95
+ // within one stroke). Dividers draw at base opacity 1 (nothing sets it
96
+ // before this pass), so set the line alpha directly; `restore` resets it.
97
+ for (let i = 0; i < xs.length; i++) {
98
+ const a = alphas[i] ?? 1;
99
+ if (a <= 0.02)
100
+ continue;
101
+ ctx.globalAlpha = a;
102
+ const px = Math.round(xs[i]) + 0.5;
103
+ ctx.beginPath();
104
+ ctx.moveTo(px, 0);
105
+ ctx.lineTo(px, height);
106
+ ctx.stroke();
107
+ }
61
108
  }
62
- ctx.stroke();
63
109
  ctx.restore();
64
110
  }
111
+ /**
112
+ * Per-line opacity for {@link drawDividers} in `'all'` mode: each line keys off
113
+ * the gap to its nearest neighbour — full at `fullPx`+, **zero** at `gonePx`
114
+ * and below, a quadratic ramp between. So as a zoom-out crowds the session
115
+ * lines they dim toward a clean plot — no hard drop that pops on pan.
116
+ *
117
+ * The curve must fall **superlinearly** in the gap: the veil a reader sees is
118
+ * `alpha × density = alpha / gap`, so the earlier linear ramp (`alpha = gap/f`)
119
+ * cancelled the density growth exactly and pinned a constant gray wash over the
120
+ * whole plot no matter how far out you zoomed. Quadratic-to-a-floor makes the
121
+ * wash itself → 0 as lines converge: alpha `t²` with
122
+ * `t = (gap − gonePx) / (fullPx − gonePx)`. `xs` ascending.
123
+ */
124
+ export function dividerAlphas(xs, gonePx, fullPx) {
125
+ return xs.map((x, i) => {
126
+ const left = i > 0 ? x - xs[i - 1] : Infinity;
127
+ const right = i < xs.length - 1 ? xs[i + 1] - x : Infinity;
128
+ const gap = Math.min(left, right);
129
+ const t = Math.max(0, Math.min(1, (gap - gonePx) / (fullPx - gonePx)));
130
+ return t * t;
131
+ });
132
+ }
65
133
  //# sourceMappingURL=grid.js.map
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export type { XAxisProps } from './XAxis.js';
31
31
  export type { AxisTransform } from './derivedTicks.js';
32
32
  export { TimeAxis } from './TimeAxis.js';
33
33
  export { CategoryAxis } from './CategoryAxis.js';
34
- export type { AxisFormat } from './format.js';
34
+ export type { AxisFormat, CursorFormat } from './format.js';
35
35
  export { LineChart } from './LineChart.js';
36
36
  export type { LineChartProps } from './LineChart.js';
37
37
  export { BandChart } from './BandChart.js';
@@ -48,7 +48,7 @@ export { Candlestick } from './Candlestick.js';
48
48
  export type { CandlestickProps } from './Candlestick.js';
49
49
  export type { CandleVariant, ColorBy } from './ohlc.js';
50
50
  export { scaleTradingTime } from './tradingTimeScale.js';
51
- export type { TradingTimeScale, DiscontinuityProvider, } from './tradingTimeScale.js';
51
+ export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
52
52
  export { scaleBand } from './bandScale.js';
53
53
  export type { ScaleBand } from './bandScale.js';
54
54
  export { Region, Baseline, Marker } from './annotations.js';
package/dist/theme.d.ts CHANGED
@@ -111,6 +111,19 @@ export interface ChartTheme {
111
111
  * boundary reads as structural.
112
112
  */
113
113
  readonly sessionDivider?: string;
114
+ /**
115
+ * The stacked date style's **band** row (the segmented second row: zebra
116
+ * date/month/year cells with left-aligned labels + dividers). `fill` is the
117
+ * shaded (odd-parity) cell background — "could be a background color" per
118
+ * the design; `divider` the turn line (falls back to {@link grid}); `label`
119
+ * the band-label ink (falls back to {@link title}.color → {@link label}).
120
+ * Optional; the whole band row is stacked-only.
121
+ */
122
+ readonly band?: {
123
+ readonly fill: string;
124
+ readonly divider?: string;
125
+ readonly label?: string;
126
+ };
114
127
  /**
115
128
  * Typography for the axis **title** — the rotated y-axis unit strip and the
116
129
  * x-axis label (distinct from the per-tick `label` colour above). Omit a
package/dist/theme.js CHANGED
@@ -112,6 +112,11 @@ export const defaultTheme = {
112
112
  grid: '#e2e8f0',
113
113
  gridDash: [2, 2],
114
114
  sessionDivider: '#cbd5e1', // slate-300 — a step stronger than the gridlines
115
+ band: {
116
+ fill: '#f8fafc', // slate-50 — the zebra shade on the stacked band row
117
+ divider: '#cbd5e1', // slate-300 turn line
118
+ label: '#334155', // slate-700 ink for band labels
119
+ },
115
120
  },
116
121
  font: {
117
122
  family: 'system-ui, -apple-system, sans-serif',