@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/CHANGELOG.md +149 -2
- package/dist/ChartContainer.d.ts +30 -2
- package/dist/ChartContainer.js +6 -2
- package/dist/Layers.js +120 -18
- package/dist/XAxis.d.ts +44 -1
- package/dist/XAxis.js +207 -52
- package/dist/YAxis.d.ts +9 -1
- package/dist/YAxis.js +4 -4
- package/dist/annotations.d.ts +1 -1
- package/dist/annotations.js +17 -6
- package/dist/context.d.ts +8 -0
- package/dist/derivedTicks.d.ts +36 -0
- package/dist/derivedTicks.js +92 -0
- package/dist/grid.d.ts +32 -5
- package/dist/grid.js +81 -13
- package/dist/index.d.ts +1 -0
- package/dist/theme.d.ts +13 -0
- package/dist/theme.js +5 -0
- package/dist/tickLadder.d.ts +145 -22
- package/dist/tickLadder.js +627 -35
- package/dist/tradingTimeScale.d.ts +96 -12
- package/dist/tradingTimeScale.js +119 -5
- package/package.json +4 -4
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
|
|
11
|
-
const
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
?
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
163
|
-
|
|
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
|
-
(
|
|
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
|
-
//
|
|
191
|
-
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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: {
|
package/dist/annotations.d.ts
CHANGED
|
@@ -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
|
package/dist/annotations.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|