@wick-charts/react 0.3.6 → 0.4.1

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.
@@ -97,7 +97,41 @@ export function InfoBar({ sort = 'none', format = defaultInfoBarFormat, children
97
97
  }
98
98
  }
99
99
 
100
- if (snapshots.length === 0) return null;
100
+ // No data yet (initial mount, before the first series tick) render a
101
+ // zero-content placeholder *for the default UI only* so the header reserves
102
+ // its real height from the very first paint. Without this the header grows
103
+ // when InfoBar pops in, ChartContainer's header-measure observer fires
104
+ // `setPadding`, and the canvas + axes visibly shift down on the next RAF.
105
+ //
106
+ // The render-prop variant (`children`) keeps the legacy "return null"
107
+ // behavior — its layout depends on user-supplied JSX, so we can't
108
+ // synthesise a meaningful placeholder.
109
+ if (snapshots.length === 0) {
110
+ if (children) return null;
111
+
112
+ return (
113
+ <div
114
+ data-tooltip-legend=""
115
+ aria-hidden="true"
116
+ style={{
117
+ display: 'flex',
118
+ alignItems: 'center',
119
+ gap: 4,
120
+ padding: '4px 8px',
121
+ flexShrink: 0,
122
+ fontSize: theme.typography.fontSize,
123
+ fontFamily: theme.typography.fontFamily,
124
+ fontVariantNumeric: 'tabular-nums',
125
+ visibility: 'hidden',
126
+ pointerEvents: 'none',
127
+ }}
128
+ >
129
+ {/* Non-breaking space keeps line-height intact so the div claims its
130
+ real rendered height instead of collapsing to padding-only. */}
131
+ <span>&nbsp;</span>
132
+ </div>
133
+ );
134
+ }
101
135
 
102
136
  if (children) {
103
137
  return (
@@ -1,6 +1,12 @@
1
1
  import { type CSSProperties, useMemo } from 'react';
2
2
 
3
- import { type ChartTheme, type TimePoint, formatCompact, resolveCandlestickBodyColor } from '@wick-charts/core';
3
+ import {
4
+ type AxisBound,
5
+ type ChartTheme,
6
+ type TimePoint,
7
+ formatCompact,
8
+ resolveCandlestickBodyColor,
9
+ } from '@wick-charts/core';
4
10
 
5
11
  import { BarSeries } from '../BarSeries';
6
12
  import { ChartContainer } from '../ChartContainer';
@@ -12,6 +18,23 @@ export type SparklineValuePosition = 'left' | 'right' | 'none';
12
18
  export interface SparklineProps {
13
19
  /** Data points plotted by the sparkline. A flat `TimePoint[]` — the sparkline only ever shows one tiny line/bar. */
14
20
  data: TimePoint[];
21
+ /**
22
+ * Streaming-window mode: viewport is fixed at `capacity` bars wide. Pass
23
+ * at least two seed points in `data` so the initial window can infer the
24
+ * tick interval.
25
+ *
26
+ * `align` controls where the seed sits at mount:
27
+ * - `'right'` *(default)* — seed flush with the right edge; each tick
28
+ * shifts the viewport left by one interval and the new tick lands at
29
+ * the right edge.
30
+ * - `'left'` — seed flush with the left edge; the viewport is held in
31
+ * place until empty bars on the right are consumed, then normal
32
+ * tail-scroll resumes.
33
+ * - `'offscreen'` — seed starts one interval past the right edge so the
34
+ * first tick's tail-scroll animates it onto canvas (a brief "drive-in"
35
+ * effect).
36
+ */
37
+ flow?: { capacity: number; align?: 'left' | 'right' | 'offscreen' };
15
38
  /** Visual theme. Drives series colour, background gradient, and the change-direction colours used in the value block. */
16
39
  theme: ChartTheme;
17
40
  /** 'line' (default) or 'bar' */
@@ -35,6 +58,15 @@ export interface SparklineProps {
35
58
  };
36
59
  /** @deprecated Use {@link area} instead. */
37
60
  areaFill?: boolean;
61
+ /**
62
+ * Fixed Y-axis bounds. Omit a side (or pass `'auto'`) to keep that edge
63
+ * auto-scaled. Useful to pin a baseline (`{ min: 0 }`) or hold a stable
64
+ * window so streaming ticks don't rescale the line.
65
+ *
66
+ * Each bound is an {@link AxisBound}: a number, `'auto'`, a percentage
67
+ * offset string (`'+10%'`), or a `(values) => number` reducer.
68
+ */
69
+ yRange?: { min?: AxisBound; max?: AxisBound };
38
70
  /** Chart width (default: 140) */
39
71
  width?: number;
40
72
  /** Overall height (default: 48) */
@@ -77,6 +109,8 @@ export function Sparkline({
77
109
  negativeColor,
78
110
  area,
79
111
  areaFill,
112
+ yRange,
113
+ flow,
80
114
  width = 140,
81
115
  height = 48,
82
116
  strokeWidth = 1.5,
@@ -95,6 +129,58 @@ export function Sparkline({
95
129
  change.positive ? theme.candlestick.up.body : theme.candlestick.down.body,
96
130
  );
97
131
 
132
+ // Previously Sparkline kept its own running min/max in a useRef and handed
133
+ // a padded Y range to ChartContainer via `axis.y.{min,max}`. That worked
134
+ // around the chart's default auto-Y "jumps" on streamed wild values, but
135
+ // it had a hidden cost: every new data prop made the memo emit a fresh
136
+ // `{min, max}` object, which ChartContainer fed into `chart.setAxis`, and
137
+ // setAxis SNAPS Y (sets `#yInited = false` and calls `updateYRange(true)`).
138
+ // Result: every streaming tick snapped Y without animation, which is the
139
+ // jerky behaviour you saw. The chart core now has sticky-Y bounds + a
140
+ // `viewportChange` emit on Y advance, so the chart handles streaming
141
+ // stability itself — Sparkline can drop its local fix.
142
+
143
+ // Captured-at-mount viewport for flow mode. Three layouts, see the
144
+ // `flow.align` docstring on SparklineProps for the user-facing summary.
145
+ //
146
+ // - 'left' uses the `{ from, bars }` form, which arms the viewport's
147
+ // warm-up hold (#holdUntilFilled) so it stays put while empty bars on
148
+ // the right are consumed, then releases to normal tail-scroll.
149
+ // - 'right' and 'offscreen' use `{ from, to }`, which leaves the hold off
150
+ // so tail-scroll kicks in on the first tick. The only difference is
151
+ // `to`: at `last` the seed sits flush right; at `last - interval` the
152
+ // seed sits one interval past the right edge and the first tick's scroll
153
+ // animates it into view.
154
+ //
155
+ // Requires at least 2 seed points so `interval` can be inferred; falls
156
+ // back to undefined otherwise (chart fits to data normally). Subsequent
157
+ // renders don't recompute because ChartContainer ignores viewport prop
158
+ // changes after mount.
159
+ const viewport = useMemo(() => {
160
+ if (!flow || data.length < 2) return undefined;
161
+
162
+ const interval = data[1].time - data[0].time;
163
+ if (interval <= 0) return undefined;
164
+
165
+ const align = flow.align ?? 'right';
166
+
167
+ if (align === 'left') {
168
+ return {
169
+ maxVisibleBars: flow.capacity,
170
+ initialRange: { from: data[0].time, bars: flow.capacity } as const,
171
+ };
172
+ }
173
+
174
+ const last = data[data.length - 1].time;
175
+ const to = align === 'offscreen' ? last - interval : last;
176
+ const from = to - flow.capacity * interval;
177
+
178
+ return {
179
+ maxVisibleBars: flow.capacity,
180
+ initialRange: { from, to } as const,
181
+ };
182
+ }, []);
183
+
98
184
  const valueBlock = valuePosition !== 'none' && (
99
185
  <div
100
186
  style={{
@@ -166,13 +252,19 @@ export function Sparkline({
166
252
  <ChartContainer
167
253
  theme={theme}
168
254
  axis={{
169
- y: { visible: false, width: 0 },
255
+ // `min`/`max` are stable user props (not recomputed per tick), so
256
+ // ChartContainer's setAxis effect — keyed on the primitive bound
257
+ // values — only re-applies on an actual change, never per stream
258
+ // tick. This is why a fixed `yRange` is safe where the old
259
+ // recompute-every-update min/max was not (see note above).
260
+ y: { visible: false, width: 0, min: yRange?.min, max: yRange?.max },
170
261
  x: { visible: false, height: 0 },
171
262
  }}
172
263
  padding={{ top: 5, right: 0, bottom: 0, left: 0 }}
173
264
  gradient={gradient}
174
265
  interactive={false}
175
266
  grid={{ visible: false }}
267
+ viewport={viewport}
176
268
  >
177
269
  {variant === 'line' ? (
178
270
  <LineSeries
@@ -192,6 +284,7 @@ export function Sparkline({
192
284
  colors: [resolvedColor, resolvedNegColor],
193
285
  barWidthRatio: 0.7,
194
286
  stacking: 'off',
287
+ anchor: 'right',
195
288
  }}
196
289
  />
197
290
  )}
@@ -1,16 +1,9 @@
1
1
  import { useLayoutEffect, useRef } from 'react';
2
2
 
3
- import { formatTime, resolveAxisFontSize, resolveAxisTextColor } from '@wick-charts/core';
3
+ import { mountAxisLabels } from '@wick-charts/core';
4
4
 
5
5
  import { useChartInstance } from '../context';
6
6
  import { useVisibleRange } from '../store-bridge';
7
- import { AXIS_LABEL_CLEANUP_MS, AXIS_LABEL_FADE_CSS } from './axisFade';
8
-
9
- interface TrackedTick {
10
- opacity: number;
11
- addedAt: number;
12
- fadedAt?: number;
13
- }
14
7
 
15
8
  export interface TimeAxisProps {
16
9
  /** Desired number of labels (≥ 2). Overrides chart-level `axis.x.labelCount`. */
@@ -21,7 +14,11 @@ export interface TimeAxisProps {
21
14
 
22
15
  export function TimeAxis({ labelCount, minLabelSpacing }: TimeAxisProps = {}) {
23
16
  const chart = useChartInstance();
24
- useVisibleRange(chart); // subscribe to viewport changes so ticks re-render
17
+ // Subscribe so the container re-renders when chart geometry shifts
18
+ // (yAxisWidth / xAxisHeight can change on resize, legend mount, etc.).
19
+ useVisibleRange(chart);
20
+
21
+ const containerRef = useRef<HTMLDivElement | null>(null);
25
22
 
26
23
  useLayoutEffect(() => {
27
24
  chart.setTimeAxisLabelDensity({
@@ -33,49 +30,17 @@ export function TimeAxis({ labelCount, minLabelSpacing }: TimeAxisProps = {}) {
33
30
  chart.setTimeAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
34
31
  };
35
32
  }, [chart, labelCount, minLabelSpacing]);
36
- const theme = chart.getTheme();
37
- const dataInterval = chart.getDataInterval();
38
- const { ticks: currentTicks, tickInterval } = chart.timeScale.niceTickValues(dataInterval);
39
- const currentSet = new Set(currentTicks);
40
-
41
- // Persistent map: tick value → tracked state
42
- const mapRef = useRef<Map<number, TrackedTick>>(new Map());
43
- const map = mapRef.current;
44
- const now = performance.now();
45
33
 
46
- // Mark current ticks as visible
47
- for (const t of currentTicks) {
48
- if (!map.has(t)) {
49
- map.set(t, { opacity: 1, addedAt: now });
50
- } else {
51
- map.get(t)!.opacity = 1;
52
- }
53
- }
54
-
55
- // Mark missing ticks for fade-out
56
- for (const [t, entry] of map) {
57
- if (!currentSet.has(t)) {
58
- if (entry.opacity !== 0) {
59
- entry.opacity = 0;
60
- entry.fadedAt = now;
61
- }
62
- }
63
- }
64
-
65
- // Clean up ticks that have finished fading. Buffer = AXIS_LABEL_FADE_MS + 250
66
- // (one transition + a frame margin) so the DOM node sticks around past the
67
- // visible fade.
68
- for (const [t, entry] of map) {
69
- if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > AXIS_LABEL_CLEANUP_MS) {
70
- map.delete(t);
71
- }
72
- }
34
+ useLayoutEffect(() => {
35
+ const container = containerRef.current;
36
+ if (container === null) return;
73
37
 
74
- // Collect all ticks to render (current + fading out)
75
- const allTicks = Array.from(map.entries());
38
+ return mountAxisLabels({ chart, container, axis: 'x' });
39
+ }, [chart]);
76
40
 
77
41
  return (
78
42
  <div
43
+ ref={containerRef}
79
44
  style={{
80
45
  position: 'absolute',
81
46
  left: 0,
@@ -86,30 +51,6 @@ export function TimeAxis({ labelCount, minLabelSpacing }: TimeAxisProps = {}) {
86
51
  display: 'flex',
87
52
  alignItems: 'center',
88
53
  }}
89
- >
90
- {allTicks.map(([time, entry]) => {
91
- const x = chart.timeScale.timeToX(time);
92
- return (
93
- <span
94
- key={time}
95
- style={{
96
- position: 'absolute',
97
- left: x,
98
- transform: 'translateX(-50%)',
99
- color: resolveAxisTextColor(theme, 'x'),
100
- fontSize: resolveAxisFontSize(theme, 'x'),
101
- fontFamily: theme.typography.fontFamily,
102
- userSelect: 'none',
103
- whiteSpace: 'nowrap',
104
- opacity: entry.opacity,
105
- transition: AXIS_LABEL_FADE_CSS,
106
- willChange: 'opacity',
107
- }}
108
- >
109
- {formatTime(time, tickInterval)}
110
- </span>
111
- );
112
- })}
113
- </div>
54
+ />
114
55
  );
115
56
  }
package/src/ui/Title.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from 'react';
1
+ import { type CSSProperties, type ReactNode, memo } from 'react';
2
2
 
3
3
  import { useTheme } from '../ThemeContext';
4
4
 
@@ -32,31 +32,35 @@ export interface TitleProps {
32
32
  * </ChartContainer>
33
33
  * ```
34
34
  */
35
- export function Title({ children, sub, style }: TitleProps) {
36
- const theme = useTheme();
37
- return (
38
- <div
39
- data-chart-title=""
40
- style={{
41
- display: 'flex',
42
- alignItems: 'baseline',
43
- gap: 6,
44
- padding: '6px 8px 0',
45
- flexShrink: 0,
46
- fontFamily: theme.typography.fontFamily,
47
- fontSize: theme.typography.fontSize,
48
- fontWeight: 600,
49
- color: theme.tooltip.textColor,
50
- pointerEvents: 'none',
51
- ...style,
52
- }}
53
- >
54
- {children != null && children !== false && <span>{children}</span>}
55
- {sub != null && sub !== false && (
56
- <span style={{ fontWeight: 400, color: theme.axis.textColor, fontSize: theme.axis.fontSize }}>
57
- {sub}
58
- </span>
59
- )}
60
- </div>
61
- );
62
- }
35
+ export const Title = memo(
36
+ function Title({ children, sub, style }: TitleProps) {
37
+ const theme = useTheme();
38
+ return (
39
+ <div
40
+ data-chart-title=""
41
+ style={{
42
+ display: 'flex',
43
+ alignItems: 'baseline',
44
+ gap: 6,
45
+ padding: '6px 8px 0',
46
+ flexShrink: 0,
47
+ fontFamily: theme.typography.fontFamily,
48
+ fontSize: theme.typography.fontSize,
49
+ fontWeight: 600,
50
+ color: theme.tooltip.textColor,
51
+ pointerEvents: 'none',
52
+ ...style,
53
+ }}
54
+ >
55
+ {children != null && children !== false && <span>{children}</span>}
56
+ {sub != null && sub !== false && (
57
+ <span style={{ fontWeight: 400, color: theme.axis.textColor, fontSize: theme.axis.fontSize }}>{sub}</span>
58
+ )}
59
+ </div>
60
+ );
61
+ },
62
+ // Explicit comparator: equivalent to default shallow-compare, but avoids the
63
+ // dev-only Profiler/highlight noise observed with bare `memo` (see
64
+ // facebook/react#19778).
65
+ (prev, next) => prev.children === next.children && prev.sub === next.sub && prev.style === next.style,
66
+ );
package/src/ui/YAxis.tsx CHANGED
@@ -1,16 +1,9 @@
1
1
  import { useLayoutEffect, useRef } from 'react';
2
2
 
3
- import { resolveAxisFontSize, resolveAxisTextColor, type ValueFormatter } from '@wick-charts/core';
3
+ import { type ValueFormatter, mountAxisLabels } from '@wick-charts/core';
4
4
 
5
5
  import { useChartInstance } from '../context';
6
6
  import { useYRange } from '../store-bridge';
7
- import { AXIS_LABEL_CLEANUP_MS, AXIS_LABEL_FADE_CSS } from './axisFade';
8
-
9
- interface TrackedTick {
10
- opacity: number;
11
- addedAt: number;
12
- fadedAt?: number;
13
- }
14
7
 
15
8
  export interface YAxisProps {
16
9
  /**
@@ -29,7 +22,9 @@ export interface YAxisProps {
29
22
 
30
23
  export function YAxis({ format, labelCount, minLabelSpacing }: YAxisProps = {}) {
31
24
  const chart = useChartInstance();
32
- useYRange(chart); // subscribe to viewport changes so ticks re-render
25
+ useYRange(chart);
26
+
27
+ const containerRef = useRef<HTMLDivElement | null>(null);
33
28
 
34
29
  // Route the prop through yScale so the *same* formatter drives every
35
30
  // surface that reads `yScale.formatY()` (Crosshair, YLabel fallback).
@@ -50,42 +45,16 @@ export function YAxis({ format, labelCount, minLabelSpacing }: YAxisProps = {})
50
45
  };
51
46
  }, [chart, labelCount, minLabelSpacing]);
52
47
 
53
- const theme = chart.getTheme();
54
- const currentTicks = chart.yScale.niceTickValues();
55
- const currentSet = new Set(currentTicks);
56
-
57
- const mapRef = useRef<Map<number, TrackedTick>>(new Map());
58
- const map = mapRef.current;
59
- const now = performance.now();
60
-
61
- for (const p of currentTicks) {
62
- if (!map.has(p)) {
63
- map.set(p, { opacity: 1, addedAt: now });
64
- } else {
65
- map.get(p)!.opacity = 1;
66
- }
67
- }
68
-
69
- for (const [p, entry] of map) {
70
- if (!currentSet.has(p)) {
71
- if (entry.opacity !== 0) {
72
- entry.opacity = 0;
73
- entry.fadedAt = now;
74
- }
75
- }
76
- }
77
-
78
- // Cleanup buffer matches the shared AXIS_LABEL_CLEANUP_MS — see axisFade.ts.
79
- for (const [p, entry] of map) {
80
- if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > AXIS_LABEL_CLEANUP_MS) {
81
- map.delete(p);
82
- }
83
- }
48
+ useLayoutEffect(() => {
49
+ const container = containerRef.current;
50
+ if (container === null) return;
84
51
 
85
- const allTicks = Array.from(map.entries());
52
+ return mountAxisLabels({ chart, container, axis: 'y' });
53
+ }, [chart]);
86
54
 
87
55
  return (
88
56
  <div
57
+ ref={containerRef}
89
58
  style={{
90
59
  position: 'absolute',
91
60
  right: 0,
@@ -94,31 +63,6 @@ export function YAxis({ format, labelCount, minLabelSpacing }: YAxisProps = {})
94
63
  width: chart.yAxisWidth,
95
64
  pointerEvents: 'none',
96
65
  }}
97
- >
98
- {allTicks.map(([price, entry]) => {
99
- const y = chart.yScale.valueToY(price);
100
- return (
101
- <span
102
- key={price}
103
- style={{
104
- position: 'absolute',
105
- right: 8,
106
- top: y,
107
- transform: 'translateY(-50%)',
108
- color: resolveAxisTextColor(theme, 'y'),
109
- fontSize: resolveAxisFontSize(theme, 'y'),
110
- fontFamily: theme.typography.fontFamily,
111
- fontVariantNumeric: 'tabular-nums',
112
- userSelect: 'none',
113
- opacity: entry.opacity,
114
- transition: AXIS_LABEL_FADE_CSS,
115
- willChange: 'opacity',
116
- }}
117
- >
118
- {chart.yScale.formatY(price)}
119
- </span>
120
- );
121
- })}
122
- </div>
66
+ />
123
67
  );
124
68
  }
@@ -1,23 +0,0 @@
1
- /**
2
- * Axis-label fade timing — shared between {@link TimeAxis} and {@link YAxis}.
3
- *
4
- * The fade is a pure CSS opacity transition, not Animator-driven, because the
5
- * label set itself is rebuilt on every render: a tick that "leaves" the
6
- * range becomes a separate DOM node fading out while a new node fades in,
7
- * and inline `transition` is the cheapest way to crossfade them without a
8
- * per-tick Animator instance.
9
- *
10
- * The duration matches the chart-level `DEFAULT_ENTER_MS` / `streamTick` so
11
- * label transitions land in lockstep with the X re-fit, Y range chase, and
12
- * series live-track. Cleanup buffer leaves the node mounted past the
13
- * visible fade so React doesn't unmount it mid-transition.
14
- */
15
-
16
- const AXIS_LABEL_FADE_MS = 250;
17
-
18
- /** Inline `style.transition` value the axis label spans use. */
19
- export const AXIS_LABEL_FADE_CSS = `opacity ${AXIS_LABEL_FADE_MS / 1000}s ease`;
20
-
21
- /** Time after which a faded-out tick can be dropped from the persistent map.
22
- * `2 * AXIS_LABEL_FADE_MS` — one transition plus a frame margin. */
23
- export const AXIS_LABEL_CLEANUP_MS = AXIS_LABEL_FADE_MS * 2;