Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@wick-charts/react

Package Overview
Dependencies
Maintainers
1
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@wick-charts/react - npm Package Compare versions

Comparing version
0.2.2
to
0.2.3
+107
src/BarSeries.tsx
import { useEffect, useLayoutEffect, useRef } from 'react';
import { type BarSeriesOptions, type TimePoint, normalizeTime } from '@wick-charts/core';
import { useChartInstance } from './context';
export interface BarSeriesProps {
/** Array of datasets — one per layer. A single-layer bar chart uses `[data]`. */
data: TimePoint[][];
options?: Partial<BarSeriesOptions>;
/** Stable series ID — same value across remounts. */
id?: string;
}
/** Only fall back to a full `setSeriesData` replace when more than this many new
* points appear in a single tick — otherwise streamed updates would always look
* like bulk loads and the renderer would clear its entrance-animation entries. */
const BULK_THRESHOLD = 20;
export function BarSeries({ data, options, id: idProp }: BarSeriesProps) {
const chart = useChartInstance();
const seriesRef = useRef<string | null>(null);
const prevLensRef = useRef<number[]>([]);
const prevFirstTimesRef = useRef<(number | null)[]>([]);
const prevLastTimesRef = useRef<(number | null)[]>([]);
useLayoutEffect(() => {
const id = chart.addBarSeries({ ...options, layers: data.length, id: idProp });
seriesRef.current = id;
prevLensRef.current = new Array(data.length).fill(0);
prevFirstTimesRef.current = new Array(data.length).fill(null);
prevLastTimesRef.current = new Array(data.length).fill(null);
return () => {
chart.removeSeries(id);
seriesRef.current = null;
prevLensRef.current = [];
prevFirstTimesRef.current = [];
prevLastTimesRef.current = [];
};
}, [chart, data.length, idProp]);
useLayoutEffect(() => {
const id = seriesRef.current;
if (!id) return;
chart.batch(() => {
for (let i = 0; i < data.length; i++) {
const layer = data[i];
const prevLen = prevLensRef.current[i] ?? 0;
const prevFirst = prevFirstTimesRef.current[i] ?? null;
if (layer.length === 0) {
chart.setSeriesData(id, [], i);
prevLensRef.current[i] = 0;
prevFirstTimesRef.current[i] = null;
prevLastTimesRef.current[i] = null;
continue;
}
const firstTime = normalizeTime(layer[0].time);
const lastTime = normalizeTime(layer[layer.length - 1].time);
const prevLast = prevLastTimesRef.current[i] ?? null;
const shifted = prevFirst !== null && prevFirst !== firstTime;
const added = layer.length - prevLen;
const hasNewLast = prevLast !== null && prevLast !== lastTime;
// Rolling-window slide (maxPoints cap): drop oldest, append newest,
// length unchanged. Sync prefix then appendData the new tail so the
// entrance animation fires instead of getting wiped by setSeriesData.
if (shifted && added === 0 && hasNewLast) {
chart.setSeriesData(id, layer.slice(0, -1), i);
chart.appendData(id, layer[layer.length - 1], i);
} else if (prevLen === 0 || layer.length < prevLen || added > BULK_THRESHOLD || shifted) {
chart.setSeriesData(id, layer, i);
} else if (layer.length === prevLen) {
chart.updateData(id, layer[layer.length - 1], i);
} else {
for (let j = prevLen; j < layer.length; j++) {
chart.appendData(id, layer[j], i);
}
}
prevLensRef.current[i] = layer.length;
prevFirstTimesRef.current[i] = firstTime;
prevLastTimesRef.current[i] = lastTime;
}
});
}, [chart, data]);
useEffect(() => {
if (seriesRef.current && options) {
chart.updateSeriesOptions(seriesRef.current, options);
}
}, [
chart,
options?.colors?.join(','),
options?.barWidthRatio,
options?.stacking,
options?.entryAnimation,
options?.enterAnimation,
options?.entryMs,
options?.enterMs,
options?.smoothMs,
]);
return null;
}
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { CandlestickSeriesOptions, OHLCInput } from '@wick-charts/core';
import { normalizeTime } from '@wick-charts/core';
import { useChartInstance } from './context';
/** Only fall back to a full `setSeriesData` replace when more than this many new
* candles appear in a single tick. Streamed bursts (OHLCStream emits up to ~8
* per 500ms) must stay under this so their appendData path still fires entrance
* animations; history loads (50/batch) deliberately exceed it. */
const BULK_THRESHOLD = 20;
export interface CandlestickSeriesProps {
data: OHLCInput[];
options?: Partial<CandlestickSeriesOptions>;
/** Stable series ID — same value across remounts. */
id?: string;
}
export function CandlestickSeries({ data, options, id: idProp }: CandlestickSeriesProps) {
const chart = useChartInstance();
const seriesRef = useRef<string | null>(null);
const prevLenRef = useRef(0);
const prevFirstTimeRef = useRef<number | null>(null);
const prevLastTimeRef = useRef<number | null>(null);
useLayoutEffect(() => {
const id = chart.addCandlestickSeries({ ...options, id: idProp });
seriesRef.current = id;
return () => {
chart.removeSeries(id);
seriesRef.current = null;
prevLenRef.current = 0;
prevFirstTimeRef.current = null;
prevLastTimeRef.current = null;
};
}, [chart, idProp]);
useLayoutEffect(() => {
const id = seriesRef.current;
if (!id) return;
if (data.length === 0) {
// Explicit clear
chart.setSeriesData(id, []);
prevLenRef.current = 0;
prevFirstTimeRef.current = null;
prevLastTimeRef.current = null;
return;
}
const prevLen = prevLenRef.current;
const prevFirst = prevFirstTimeRef.current;
const prevLast = prevLastTimeRef.current;
const firstTime = normalizeTime(data[0].time);
const lastTime = normalizeTime(data[data.length - 1].time);
const shifted = prevFirst !== null && prevFirst !== firstTime;
const added = data.length - prevLen;
const hasNewLast = prevLast !== null && prevLast !== lastTime;
// Rolling-window slide: same array length but first AND last timestamps
// advanced (old point dropped, new point appended). Must NOT fall through
// to a full `setSeriesData` — that would wipe the entrance-animation
// entries. Sync the stable prefix, then appendData the fresh tail so the
// renderer registers an entry for just the new point.
if (shifted && added === 0 && hasNewLast) {
chart.setSeriesData(id, data.slice(0, -1));
chart.appendData(id, data[data.length - 1]);
} else if (prevLen === 0 || data.length < prevLen || added > BULK_THRESHOLD || shifted) {
chart.setSeriesData(id, data);
} else if (data.length === prevLen) {
// Same length, same timestamps — last candle updated in place.
chart.updateData(id, data[data.length - 1]);
} else {
for (let i = prevLen; i < data.length; i++) {
chart.appendData(id, data[i]);
}
}
prevLenRef.current = data.length;
prevFirstTimeRef.current = firstTime;
prevLastTimeRef.current = lastTime;
}, [chart, data]);
useEffect(() => {
if (seriesRef.current && options) {
chart.updateSeriesOptions(seriesRef.current, options);
}
}, [
chart,
options?.upColor,
options?.downColor,
options?.wickUpColor,
options?.wickDownColor,
options?.bodyWidthRatio,
options?.bodyGradient,
options?.candleGradient,
options?.entryAnimation,
options?.enterAnimation,
options?.entryMs,
options?.enterMs,
options?.smoothMs,
]);
return null;
}
import {
type CSSProperties,
Children,
Fragment,
type ReactElement,
type ReactNode,
isValidElement,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { type AxisConfig, ChartInstance, type ChartOptions, type ChartTheme } from '@wick-charts/core';
type PerfOption = NonNullable<ChartOptions['perf']>;
import { ChartContext } from './context';
import { ThemeProvider, useThemeOptional } from './ThemeContext';
import { InfoBar } from './ui/InfoBar';
import { Legend, type LegendProps } from './ui/Legend';
import { PieLegend, type PieLegendProps } from './ui/PieLegend';
import { Title } from './ui/Title';
/** Props for the {@link ChartContainer} component. */
export interface ChartContainerProps {
/** Series components and UI overlays (Tooltip, TimeAxis, etc.) rendered inside the chart. */
children?: ReactNode;
/** Visual theme. Changing this at runtime will update all themed elements. */
theme?: ChartTheme;
/** Grouped axis configuration (Y/X visibility, bounds, sizing). */
axis?: AxisConfig;
/**
* Viewport padding. `top`/`bottom` are in pixels. `left`/`right` accept either pixels (`50`)
* or data intervals (`{ intervals: 3 }`). Set to 0 for edge-to-edge sparklines. Applied on mount only.
* Defaults: `{ top: 20, bottom: 20, right: { intervals: 3 }, left: { intervals: 0 } }`.
*/
padding?: {
top?: number;
bottom?: number;
right?: number | { intervals: number };
left?: number | { intervals: number };
};
/** Show the chart background gradient. Defaults to true. */
gradient?: boolean;
/** Enable zoom, pan, and crosshair interactions. Defaults to true. */
interactive?: boolean;
/** Background grid configuration. Default: `{ visible: true }`. */
grid?: { visible: boolean };
/**
* How `<Title>` and `<InfoBar>` are positioned relative to the canvas.
* - `'overlay'` (default): absolute overlays on top of the canvas — the grid
* and Y-axis labels render full-height behind the header strip.
* - `'inline'`: flex siblings above the canvas — the canvas (and grid) are
* shifted down by the measured header height, so nothing renders behind
* the title. The chart background still spans the full container.
*/
headerLayout?: 'overlay' | 'inline';
/**
* Enable runtime performance instrumentation. Off by default.
*
* - `true` — attach a {@link PerfMonitor} and render a visible HUD overlay on this chart.
* - `{ hud: true, windowMs, maxSamples, ... }` — same, with monitor options.
* - `{ hud: false, monitor }` — attach to an existing monitor without rendering the HUD.
*
* Only read at mount; changing this prop after the chart is created is ignored.
*/
perf?: PerfOption;
style?: CSSProperties;
className?: string;
}
/**
* Split children into `<Title>`, `<Legend>`, `<InfoBar>`, and the rest.
*
* Transparently walks through `<React.Fragment>` wrappers so the caller can
* use normal React patterns — e.g. wrapping children in a conditional
* fragment or returning fragments from parent components — and still get
* hoisting. Deeper component boundaries are left alone on purpose: a custom
* component that internally renders a `<Title>` / `<InfoBar>` is its own DOM
* subtree and should stay there.
*
* Exported for testing — this is pure React-children iteration with no DOM
* dependencies, so it can be asserted in Node.
*/
export function siftContainerChildren(children: ReactNode): {
titleEl: ReactElement | null;
legendEl: ReactElement<LegendProps> | null;
pieLegendEl: ReactElement<PieLegendProps> | null;
tooltipLegendEl: ReactElement | null;
overlay: ReactNode[];
} {
let titleEl: ReactElement | null = null;
let legendEl: ReactElement<LegendProps> | null = null;
let pieLegendEl: ReactElement<PieLegendProps> | null = null;
let tooltipLegendEl: ReactElement | null = null;
const overlay: ReactNode[] = [];
const visit = (child: ReactNode): void => {
if (isValidElement(child) && child.type === Fragment) {
// Unwrap fragments recursively — fragments don't produce DOM nodes,
// so a Title/Legend/InfoBar nested in one is still a layout-level sibling.
Children.forEach((child as ReactElement<{ children?: ReactNode }>).props.children, visit);
return;
}
if (isValidElement(child)) {
if (child.type === Title) {
titleEl = child;
return;
}
if (child.type === Legend) {
legendEl = child as ReactElement<LegendProps>;
return;
}
if (child.type === PieLegend) {
// `position='overlay'` opts back into the old absolute-positioned
// layout, so we leave it in the overlay array for that path only.
const typed = child as ReactElement<PieLegendProps>;
if (typed.props.position === 'overlay') {
overlay.push(child);
} else {
pieLegendEl = typed;
}
return;
}
if (child.type === InfoBar) {
tooltipLegendEl = child;
return;
}
}
overlay.push(child);
};
Children.forEach(children, visit);
return { titleEl, legendEl, pieLegendEl, tooltipLegendEl, overlay };
}
/**
* Top-level React wrapper that creates a {@link ChartInstance} and provides it to children via context.
* Owns the DOM container and canvas lifecycle; renders children as an overlay layer.
*
* Detects `<Title>`, `<InfoBar>`, and `<Legend>` children and positions them as:
* - Title + InfoBar — absolutely-positioned *overlays* stacked at the top of the canvas
* block, so the canvas (and therefore the grid) fills the full container height. The stacked
* height is measured and fed back into `chart.setPadding({ top })` so series data stays below
* them.
* - Legend — flex sibling at the bottom (or right, when `position="right"`), so its height is
* reserved by browser layout.
*/
export function ChartContainer({
children,
theme,
axis,
padding,
gradient = true,
interactive,
grid,
headerLayout = 'overlay',
perf,
style,
className,
}: ChartContainerProps) {
// Mount-only: capture the initial perf option in a ref so later renders with
// a new object identity don't recreate the chart or remount the HUD.
const perfRef = useRef(perf);
const contextTheme = useThemeOptional();
const resolvedTheme = theme ?? contextTheme ?? undefined;
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ChartInstance | null>(null);
const [_, setRevision] = useState(0);
// useLayoutEffect — synchronous, runs before paint.
useLayoutEffect(() => {
if (!containerRef.current) return;
if (chartRef.current) return;
const options: ChartOptions = {};
if (axis) options.axis = axis;
if (resolvedTheme) options.theme = resolvedTheme;
if (padding) options.padding = padding;
if (interactive !== undefined) options.interactive = interactive;
if (grid !== undefined) options.grid = grid;
if (perfRef.current !== undefined) options.perf = perfRef.current;
chartRef.current = new ChartInstance(containerRef.current, options);
// Note: the init path above already propagated `grid` into the chart. The
// effect below handles live updates, but also needs to run on the same
// commit so an initial `grid={{visible:false}}` isn't silently reset.
setRevision((r) => r + 1);
return () => {
// Destroy synchronously. A previous revision deferred this through
// `setTimeout(..., 0)` to "tolerate StrictMode" but the guard was
// broken: in the StrictMode remount sequence (cleanup → second mount →
// timeout), the check `if (!chartRef.current) instance.destroy()`
// always saw the second instance and skipped the destroy — leaking
// the first ChartInstance's canvases (hence 4 canvases per chart in
// dev). StrictMode exists precisely to exercise cleanup; a correct
// `destroy` is cheap enough to run on every cycle.
chartRef.current?.destroy();
chartRef.current = null;
};
}, []);
useEffect(() => {
if (chartRef.current && resolvedTheme) {
chartRef.current.setTheme(resolvedTheme);
}
}, [resolvedTheme]);
useEffect(() => {
if (chartRef.current && axis) {
chartRef.current.setAxis(axis);
}
}, [axis?.y?.width, axis?.y?.min, axis?.y?.max, axis?.y?.visible, axis?.x?.height, axis?.x?.visible]);
// Top-overlay height (title + info bar) — measured below. Declared here so
// the padding effect can fold it into `padding.top`.
const topOverlayRef = useRef<HTMLDivElement>(null);
const [topOverlayHeight, setTopOverlayHeight] = useState(0);
// In 'inline' mode the canvas itself is shorter (browser flex reserves the
// header height), so adding topOverlayHeight here would double-shift the
// data. Only the overlay mode needs the fold-in. Depend on `headerExtra`
// below instead of `topOverlayHeight` so inline-mode header resizes don't
// fire redundant `chart.setPadding(...)` calls (headerExtra stays 0).
const headerExtra = headerLayout === 'overlay' ? topOverlayHeight : 0;
useEffect(() => {
const current = chartRef.current;
if (!current) return;
const userTop = padding?.top ?? 20;
const merged: ChartOptions['padding'] = {
top: userTop + headerExtra,
...(padding?.bottom !== undefined ? { bottom: padding.bottom } : {}),
...(padding?.right !== undefined ? { right: padding.right } : {}),
...(padding?.left !== undefined ? { left: padding.left } : {}),
};
current.setPadding(merged);
}, [
padding?.top,
padding?.bottom,
typeof padding?.right === 'object' ? padding.right.intervals : padding?.right,
typeof padding?.left === 'object' ? padding.left.intervals : padding?.left,
headerExtra,
]);
useEffect(() => {
if (chartRef.current && grid !== undefined) {
chartRef.current.setGrid(grid);
}
}, [grid?.visible]);
const chart = chartRef.current;
const { titleEl, legendEl, pieLegendEl, tooltipLegendEl, overlay } = siftContainerChildren(children);
const legendPosition = legendEl?.props.position ?? 'bottom';
const pieLegendPosition = pieLegendEl?.props.position ?? 'bottom';
// Either legend type can pull the layout into row-mode. `Legend` and
// `PieLegend` are mutually exclusive in practice (line vs pie chart), so we
// just OR the two position checks.
const isLegendRight = legendPosition === 'right' || pieLegendPosition === 'right';
const effectiveTheme = resolvedTheme ?? chart?.getTheme();
const [gtop, gbot] = effectiveTheme?.chartGradient ?? ['transparent', 'transparent'];
const bg = effectiveTheme?.background ?? 'transparent';
const backgroundStyle = gradient ? `linear-gradient(to bottom, ${gtop} 0%, ${bg} 70%, ${gbot} 100%)` : bg;
// Measure the stacked overlay (Title + InfoBar) height and feed it
// into the padding effect above so data stays below them even though the
// canvas itself fills the whole container. Only needed in 'overlay' mode —
// 'inline' mode lets browser flex layout reserve header height directly,
// so we skip the ResizeObserver entirely and clear any stale measurement.
useLayoutEffect(() => {
if (headerLayout !== 'overlay') {
setTopOverlayHeight(0);
return;
}
const el = topOverlayRef.current;
if (!el) {
// When neither Title nor InfoBar is present the overlay wrapper
// isn't rendered — clear any stale measured height so `padding.top`
// drops back to the user's configured value on the next effect run.
setTopOverlayHeight(0);
return;
}
const update = () => setTopOverlayHeight(el.getBoundingClientRect().height);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
// `chart !== null` is in deps so the measurement re-runs once the
// ChartInstance is attached — on the first pass the overlay wrapper is
// gated behind `chart && (...)` so the ref is null; without this dep
// React wouldn't re-fire when the overlay finally mounts.
}, [titleEl !== null, tooltipLegendEl !== null, headerLayout, chart !== null]);
const headerStack = (titleEl || tooltipLegendEl) && (
<div
data-chart-header=""
data-chart-top-overlay={headerLayout === 'overlay' ? '' : undefined}
ref={topOverlayRef}
style={
headerLayout === 'overlay'
? {
position: 'absolute',
top: 0,
left: 0,
right: 0,
// Lower than the series-overlay layer below, so the floating
// <Tooltip> glass panel renders *above* Title/InfoBar
// when the cursor hovers near them.
zIndex: 2,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
}
: {
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
pointerEvents: 'none',
}
}
>
{titleEl}
{tooltipLegendEl}
</div>
);
const chartInner = (
<div
ref={containerRef}
style={{
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
}}
>
{chart && (
<ChartContext.Provider value={chart}>
<ThemeProvider value={resolvedTheme ?? chart.getTheme()}>
{headerLayout === 'overlay' && headerStack}
<div
data-chart-series-overlay=""
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 3,
}}
>
{overlay}
</div>
</ThemeProvider>
</ChartContext.Provider>
)}
</div>
);
const canvasBlock =
headerLayout === 'inline' ? (
<div
data-chart-canvas-block=""
style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, minHeight: 0 }}
>
{chart && headerStack && (
<ChartContext.Provider value={chart}>
<ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{headerStack}</ThemeProvider>
</ChartContext.Provider>
)}
{chartInner}
</div>
) : (
chartInner
);
const hoistedLegend = chart && legendEl && (
<ChartContext.Provider value={chart}>
<ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{legendEl}</ThemeProvider>
</ChartContext.Provider>
);
const hoistedPieLegend = chart && pieLegendEl && (
<ChartContext.Provider value={chart}>
<ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{pieLegendEl}</ThemeProvider>
</ChartContext.Provider>
);
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'hidden',
background: backgroundStyle,
...style,
}}
>
{/* One stable wrapper for both legend positions. Keeping the tree
structure identical means React reconciles canvasBlock in place
when `isLegendRight` flips, preserving the canvas element and
letting its ResizeObserver re-layout the chart in response to
the new flex bounds. A branching <> ↔ <div> swap would remount
the canvas and throw away chart state. */}
<div
style={{
display: 'flex',
flexDirection: isLegendRight ? 'row' : 'column',
flex: 1,
minHeight: 0,
minWidth: 0,
}}
>
{canvasBlock}
{hoistedLegend}
{hoistedPieLegend}
</div>
</div>
);
}
import { createContext, useContext } from 'react';
import type { ChartInstance } from '@wick-charts/core';
export const ChartContext = createContext<ChartInstance | null>(null);
export function useChartInstance(): ChartInstance {
const chart = useContext(ChartContext);
if (!chart) {
throw new Error('useChartInstance must be used within <ChartContainer>');
}
return chart;
}
// Re-export core (users import everything from '@wick-charts/react')
export type {
AxisBound,
AxisConfig,
BarSeriesOptions,
/** @deprecated Use {@link StackingMode} instead. */
BarStacking,
BuildHoverSnapshotsArgs,
BuildLastSnapshotsArgs,
CandlestickSeriesOptions,
ChartLayout,
ChartOptions,
ChartTheme,
CrosshairPosition,
HoverInfo,
LegendItem,
/** @deprecated Use {@link TimePoint} instead. */
LineData,
LineSeriesOptions,
OHLCData,
OHLCInput,
PieSeriesOptions,
PieSliceData,
SeriesSnapshot,
SeriesType,
SliceInfo,
SnapshotSort,
StackingMode,
ThemeConfig,
ThemePreset,
TimePoint,
TimePointInput,
TimeValue,
TooltipField,
TooltipFormatter,
TooltipPosition,
TooltipPositionArgs,
Typography,
ValueFormatter,
VisibleRange,
XAxisConfig,
YAxisConfig,
YRange,
} from '@wick-charts/core';
export {
ChartInstance,
andromeda,
ayuMirage,
buildHoverSnapshots,
buildLastSnapshots,
catppuccin,
computeTooltipPosition,
createTheme,
darkTheme,
detectInterval,
dracula,
formatCompact,
formatDate,
formatPriceAdaptive,
formatTime,
githubLight,
gruvbox,
handwritten,
highContrast,
lavenderMist,
lightPink,
lightTheme,
materialPalenight,
minimalLight,
mintBreeze,
monokaiPro,
nightOwl,
normalizeTime,
oneDarkPro,
panda,
peachCream,
quietLight,
rosePineDawn,
sandDune,
solarizedLight,
} from '@wick-charts/core';
export { BarSeries } from './BarSeries';
export { CandlestickSeries } from './CandlestickSeries';
// React components
export { ChartContainer } from './ChartContainer';
// React hooks
export { useChartInstance } from './context';
export { LineSeries } from './LineSeries';
export { PieSeries } from './PieSeries';
export {
useCrosshairPosition,
useLastYValue,
usePreviousClose,
useVisibleRange,
useYRange,
} from './store-bridge';
export { ThemeProvider, useTheme } from './ThemeContext';
export { Crosshair } from './ui/Crosshair';
export type { InfoBarProps, InfoBarRenderContext } from './ui/InfoBar';
export { InfoBar } from './ui/InfoBar';
export type { LegendItemOverride, LegendProps } from './ui/Legend';
// Legend
export { Legend } from './ui/Legend';
export { NumberFlow } from './ui/NumberFlow';
export type { PieLegendMode, PieLegendPosition, PieLegendProps, PieLegendRenderContext } from './ui/PieLegend';
export { PieLegend } from './ui/PieLegend';
export { PieTooltip } from './ui/PieTooltip';
export type { SparklineProps, SparklineValuePosition, SparklineVariant } from './ui/Sparkline';
export { Sparkline } from './ui/Sparkline';
export { TimeAxis, TimeAxis as XAxis } from './ui/TimeAxis';
export type { TitleProps } from './ui/Title';
export { Title } from './ui/Title';
export type { TooltipProps, TooltipRenderContext, TooltipSort } from './ui/Tooltip';
// UI overlays
export { Tooltip } from './ui/Tooltip';
export type { YAxisProps } from './ui/YAxis';
export { YAxis } from './ui/YAxis';
export { YLabel } from './ui/YLabel';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { type LineSeriesOptions, type TimePoint, normalizeTime } from '@wick-charts/core';
import { useChartInstance } from './context';
export interface LineSeriesProps {
/** Array of datasets — one per layer. A single line uses `[data]`. */
data: TimePoint[][];
options?: Partial<LineSeriesOptions>;
/** Stable series ID — same value across remounts. */
id?: string;
}
/** Only fall back to a full `setSeriesData` replace when more than this many new
* points appear in a single tick — otherwise streamed updates would always look
* like bulk loads and the renderer would clear its entrance-animation entries. */
const BULK_THRESHOLD = 20;
export function LineSeries({ data, options, id: idProp }: LineSeriesProps) {
const chart = useChartInstance();
const seriesRef = useRef<string | null>(null);
const prevLensRef = useRef<number[]>([]);
const prevFirstTimesRef = useRef<(number | null)[]>([]);
const prevLastTimesRef = useRef<(number | null)[]>([]);
useLayoutEffect(() => {
const id = chart.addLineSeries({ ...options, layers: data.length, id: idProp });
seriesRef.current = id;
prevLensRef.current = new Array(data.length).fill(0);
prevFirstTimesRef.current = new Array(data.length).fill(null);
prevLastTimesRef.current = new Array(data.length).fill(null);
return () => {
chart.removeSeries(id);
seriesRef.current = null;
prevLensRef.current = [];
prevFirstTimesRef.current = [];
prevLastTimesRef.current = [];
};
}, [chart, data.length, idProp]);
useLayoutEffect(() => {
const id = seriesRef.current;
if (!id) return;
chart.batch(() => {
for (let i = 0; i < data.length; i++) {
const layer = data[i];
const prevLen = prevLensRef.current[i] ?? 0;
const prevFirst = prevFirstTimesRef.current[i] ?? null;
if (layer.length === 0) {
chart.setSeriesData(id, [], i);
prevLensRef.current[i] = 0;
prevFirstTimesRef.current[i] = null;
prevLastTimesRef.current[i] = null;
continue;
}
const firstTime = normalizeTime(layer[0].time);
const lastTime = normalizeTime(layer[layer.length - 1].time);
const prevLast = prevLastTimesRef.current[i] ?? null;
const shifted = prevFirst !== null && prevFirst !== firstTime;
const added = layer.length - prevLen;
const hasNewLast = prevLast !== null && prevLast !== lastTime;
// Rolling-window slide (maxPoints cap): drop oldest, append newest,
// length unchanged. Sync prefix then appendData the new tail so the
// entrance animation fires instead of getting wiped by setSeriesData.
if (shifted && added === 0 && hasNewLast) {
chart.setSeriesData(id, layer.slice(0, -1), i);
chart.appendData(id, layer[layer.length - 1], i);
} else if (prevLen === 0 || layer.length < prevLen || added > BULK_THRESHOLD || shifted) {
chart.setSeriesData(id, layer, i);
} else if (layer.length === prevLen) {
chart.updateData(id, layer[layer.length - 1], i);
} else {
for (let j = prevLen; j < layer.length; j++) {
chart.appendData(id, layer[j], i);
}
}
prevLensRef.current[i] = layer.length;
prevFirstTimesRef.current[i] = firstTime;
prevLastTimesRef.current[i] = lastTime;
}
});
}, [chart, data]);
useEffect(() => {
if (seriesRef.current && options) {
chart.updateSeriesOptions(seriesRef.current, options);
}
}, [
chart,
options?.colors?.join(','),
options?.strokeWidth,
options?.area?.visible,
(options as { areaFill?: boolean } | undefined)?.areaFill,
options?.pulse,
options?.stacking,
options?.entryAnimation,
options?.enterAnimation,
options?.entryMs,
options?.enterMs,
options?.smoothMs,
]);
return null;
}
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { PieSeriesOptions, PieSliceData } from '@wick-charts/core';
import { useChartInstance } from './context';
export interface PieSeriesProps {
data: PieSliceData[];
options?: Partial<PieSeriesOptions>;
/** Stable series ID — same value across remounts. */
id?: string;
}
/** Pie chart series. Set `options.innerRadiusRatio` > 0 for donut. */
export function PieSeries({ data, options, id: idProp }: PieSeriesProps) {
const chart = useChartInstance();
const seriesRef = useRef<string | null>(null);
useLayoutEffect(() => {
const id = chart.addPieSeries({ ...options, id: idProp });
seriesRef.current = id;
return () => {
chart.removeSeries(id);
seriesRef.current = null;
};
}, [chart, idProp]);
useEffect(() => {
if (seriesRef.current && options) {
chart.updateSeriesOptions(seriesRef.current, options);
}
}, [
chart,
options?.innerRadiusRatio,
options?.padAngle,
options?.animate,
options?.shadow,
options?.innerShadow,
options?.colors,
options?.sliceLabels?.mode,
options?.sliceLabels?.content,
options?.sliceLabels?.fontSize,
options?.sliceLabels?.minSliceAngle,
options?.sliceLabels?.elbowLen,
options?.sliceLabels?.legPad,
options?.sliceLabels?.labelGap,
options?.sliceLabels?.distance,
options?.sliceLabels?.railWidth,
options?.sliceLabels?.balanceSides,
]);
useLayoutEffect(() => {
if (seriesRef.current) {
chart.setSeriesData(seriesRef.current, data);
}
}, [chart, data]);
return null;
}
import { useMemo, useSyncExternalStore } from 'react';
import type { ChartInstance, CrosshairPosition, VisibleRange, YRange } from '@wick-charts/core';
type ChartEvent = 'crosshairMove' | 'viewportChange' | 'dataUpdate' | 'seriesChange';
// `chart.on` is typed per-event (e.g. `crosshairMove` passes `CrosshairPosition`)
// but the React bridge only needs a generic "something changed" ping. Narrow
// via `Parameters<ChartInstance['on']>` so we stay inside the public surface
// without collapsing to `any`.
type ChartOnListener = Parameters<ChartInstance['on']>[1];
function createStore<T>(chart: ChartInstance, events: ChartEvent | ChartEvent[], getSnapshot: () => T) {
const list = Array.isArray(events) ? events : [events];
return {
subscribe: (callback: () => void) => {
const listener = callback as unknown as ChartOnListener;
for (const e of list) chart.on(e, listener);
return () => {
for (const e of list) chart.off(e, listener);
};
},
getSnapshot,
};
}
export function useVisibleRange(chart: ChartInstance): VisibleRange {
const store = useMemo(
() => createStore(chart, ['viewportChange', 'dataUpdate', 'seriesChange'], () => chart.getVisibleRange()),
[chart],
);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
export function useYRange(chart: ChartInstance): YRange {
const store = useMemo(
() => createStore(chart, ['viewportChange', 'dataUpdate', 'seriesChange'], () => chart.getYRange()),
[chart],
);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
export function useLastYValue(chart: ChartInstance, seriesId: string): { value: number; isLive: boolean } | null {
const store = useMemo(() => {
let snapshot = chart.getLastValue(seriesId);
// Remember the pixel Y that corresponds to `snapshot` so we can detect
// viewport shifts (resize, headerLayout toggle, zoom/pan) where the value
// is unchanged but the badge needs to move. Computing both prev and next
// against the current yScale would always compare equal.
let lastY = snapshot ? chart.yScale.valueToY(snapshot.value) : null;
const getSnapshot = () => snapshot;
const refresh = (): boolean => {
const next = chart.getLastValue(seriesId);
const nextY = next ? chart.yScale.valueToY(next.value) : null;
if (snapshot?.value === next?.value && snapshot?.isLive === next?.isLive && lastY === nextY) {
return false;
}
snapshot = next;
lastY = nextY;
return true;
};
return {
subscribe: (callback: () => void) => {
// The snapshot captured in useMemo can predate the series being added
// (LineSeries's useLayoutEffect runs after YLabel's initial render, so
// getLastValue returned null). Reconcile before listeners attach —
// useSyncExternalStore re-reads getSnapshot after subscribe and will
// force a re-render when the value differs from the last one it saw.
refresh();
const onChange = () => {
if (refresh()) callback();
};
chart.on('dataUpdate', onChange);
chart.on('viewportChange', onChange);
return () => {
chart.off('dataUpdate', onChange);
chart.off('viewportChange', onChange);
};
},
getSnapshot,
};
}, [chart, seriesId]);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
export function usePreviousClose(chart: ChartInstance, seriesId: string): number | null {
const store = useMemo(
() => createStore(chart, 'dataUpdate', () => chart.getPreviousClose(seriesId)),
[chart, seriesId],
);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
export function useCrosshairPosition(chart: ChartInstance): CrosshairPosition | null {
const store = useMemo(() => createStore(chart, 'crosshairMove', () => chart.getCrosshairPosition()), [chart]);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
import { createContext, useContext } from 'react';
import type { ChartTheme } from '@wick-charts/core';
const ThemeCtx = createContext<ChartTheme | null>(null);
export const ThemeProvider = ThemeCtx.Provider;
/** Read the current chart theme from context. Must be inside a ThemeProvider. */
export function useTheme(): ChartTheme {
const theme = useContext(ThemeCtx);
if (!theme) {
throw new Error('useTheme must be used within <ThemeProvider>');
}
return theme;
}
/** Read the theme from context, or return null if no provider. */
export function useThemeOptional(): ChartTheme | null {
return useContext(ThemeCtx);
}
import { formatTime } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useCrosshairPosition } from '../store-bridge';
export function Crosshair() {
const chart = useChartInstance();
const position = useCrosshairPosition(chart);
if (!position) return null;
const theme = chart.getTheme();
const dataInterval = chart.getDataInterval();
const labelStyle = {
// Blend the theme's labelBackground at 80% opacity so the axis grid
// shows through — matches TradingView-style overlays and keeps the
// badge from looking like an opaque block.
background: `color-mix(in srgb, ${theme.crosshair.labelBackground} 80%, transparent)`,
color: theme.crosshair.labelTextColor,
fontSize: theme.typography.axisFontSize,
fontFamily: theme.typography.fontFamily,
fontVariantNumeric: 'tabular-nums' as const,
padding: '2px 6px',
borderRadius: 2,
whiteSpace: 'nowrap' as const,
pointerEvents: 'none' as const,
// Sit above axis ticks (z:0) but below the YLabel badge (z:3) so the
// live last-value stays visible when the crosshair crosses its row.
zIndex: 2,
};
return (
<>
{/* Y label on right axis */}
<div
style={{
position: 'absolute',
right: 0,
top: position.mediaY,
transform: 'translateY(-50%)',
...labelStyle,
}}
>
{chart.yScale.formatY(position.y)}
</div>
{/* Time label on bottom axis */}
<div
style={{
position: 'absolute',
bottom: 0,
left: position.mediaX,
transform: 'translateX(-50%)',
...labelStyle,
}}
>
{formatTime(position.time, dataInterval)}
</div>
</>
);
}
import { type ReactNode, useLayoutEffect, useState } from 'react';
import {
type OHLCData,
type SeriesSnapshot,
type TimePoint,
type TooltipFormatter,
buildHoverSnapshots,
buildLastSnapshots,
formatCompact,
formatPriceAdaptive,
formatTime,
} from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useCrosshairPosition } from '../store-bridge';
import { useTheme } from '../ThemeContext';
import type { TooltipSort } from './Tooltip';
/** Context passed to the {@link InfoBar} render-prop. */
export interface InfoBarRenderContext {
readonly snapshots: readonly SeriesSnapshot[];
/** Timestamp displayed. In hover mode it's the crosshair time; in last-mode it's the newest point. */
readonly time: number;
/** `true` while the user's pointer is over the chart (hover mode). */
readonly isHover: boolean;
}
/** Props for the {@link InfoBar} component. */
export interface InfoBarProps {
/** Sort order for line values (default: 'none'). */
sort?: TooltipSort;
/**
* Custom formatter for every displayed number in the default UI. Called per
* cell with the field hint (`'open' | 'high' | 'low' | 'close' | 'volume' | 'value'`).
* Defaults: adaptive precision for ohlc/value, compact (K/M/B/T) for volume.
* Ignored when {@link children} is a render-prop.
*/
format?: TooltipFormatter;
/**
* Render-prop escape hatch. Receives the computed snapshots and replaces the
* entire built-in layout. Filter, reorder, or re-style rows here without
* re-implementing any data wiring.
*/
children?: (ctx: InfoBarRenderContext) => ReactNode;
}
/** Default InfoBar formatter — adaptive for ohlc/value, compact for volume. */
const defaultInfoBarFormat: TooltipFormatter = (v, field) =>
field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v);
/**
* Compact OHLC/series info bar rendered as a flex row above the chart canvas.
* Pairs with {@link Tooltip} (which then only renders its floating near-cursor part).
*
* Pass a render-prop child for a custom layout — the built-in UI is used when
* {@link children} is omitted.
*/
export function InfoBar({ sort = 'none', format = defaultInfoBarFormat, children }: InfoBarProps) {
const chart = useChartInstance();
const theme = useTheme();
const crosshair = useCrosshairPosition(chart);
const [, bump] = useState(0);
useLayoutEffect(() => {
const onOverlayChange = () => bump((n) => n + 1);
chart.on('overlayChange', onOverlayChange);
// Catch-up: a sibling series' layout effect may have registered data in
// the same commit. Bump so the next synchronous render picks it up.
if (chart.getSeriesIds().length > 0) bump((n) => n + 1);
return () => {
chart.off('overlayChange', onOverlayChange);
};
}, [chart]);
// Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
// so a crosshair event fires for offsets past the plotted data. The
// nearest-time lookup then snaps to an out-of-range timestamp and returns
// no samples. Falling back to the last-mode snapshots here keeps the bar
// populated (showing last values at 0.6 opacity) instead of blinking out
// every time the pointer grazes the y-axis.
const lastSnapshots = buildLastSnapshots(chart, { sort, cacheKey: 'infobar-last' });
let snapshots = lastSnapshots;
let displayTime = lastSnapshots.length === 0 ? 0 : Math.max(...lastSnapshots.map((s) => s.data.time));
let isHover = false;
if (crosshair !== null) {
const hoverSnapshots = buildHoverSnapshots(chart, { time: crosshair.time, sort, cacheKey: 'infobar-hover' });
if (hoverSnapshots.length > 0) {
snapshots = hoverSnapshots;
// `snapshots[0].data.time` is index-0 → shifts when `sort` reorders.
// Use the raw crosshair time (what the user is pointing at) so the
// header stays stable across sort toggles.
displayTime = crosshair.time;
isHover = true;
}
}
if (snapshots.length === 0) return null;
if (children) {
return (
<div
data-tooltip-legend=""
style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.fontSize,
fontVariantNumeric: 'tabular-nums',
opacity: isHover ? 1 : 0.6,
transition: 'opacity 0.2s ease',
pointerEvents: 'none',
}}
>
{children({ snapshots, time: displayTime, isHover })}
</div>
);
}
const dataInterval = chart.getDataInterval();
return (
<div
data-tooltip-legend=""
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
padding: '4px 8px',
flexShrink: 0,
fontSize: theme.typography.fontSize,
fontFamily: theme.typography.fontFamily,
fontVariantNumeric: 'tabular-nums',
opacity: isHover ? 1 : 0.6,
transition: 'opacity 0.2s ease',
pointerEvents: 'none',
}}
>
<span style={{ color: theme.axis.textColor, marginRight: 2 }}>{formatTime(displayTime, dataInterval)}</span>
{snapshots.map((s) => {
const isOHLC = 'open' in s.data;
if (isOHLC) {
const ohlc = s.data as OHLCData;
const isUp = ohlc.close >= ohlc.open;
const c = isUp ? theme.candlestick.upColor : theme.candlestick.downColor;
return (
<span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<LegendItem label="O" display={format(ohlc.open, 'open')} color={c} dim={theme.axis.textColor} />
<LegendItem label="H" display={format(ohlc.high, 'high')} color={c} dim={theme.axis.textColor} />
<LegendItem label="L" display={format(ohlc.low, 'low')} color={c} dim={theme.axis.textColor} />
<LegendItem label="C" display={format(ohlc.close, 'close')} color={c} dim={theme.axis.textColor} />
{ohlc.volume != null && (
<LegendItem
label="V"
display={format(ohlc.volume, 'volume')}
color={theme.axis.textColor}
dim={theme.axis.textColor}
/>
)}
</span>
);
}
const line = s.data as TimePoint;
return (
<span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: s.color,
flexShrink: 0,
}}
/>
<span style={{ color: s.color, fontWeight: 500 }}>{format(line.value, 'value')}</span>
</span>
);
})}
</div>
);
}
function LegendItem({ label, display, color, dim }: { label: string; display: string; color: string; dim: string }) {
return (
<>
<span style={{ color: dim, opacity: 0.5, marginLeft: 5 }}>{label}</span>
<span style={{ color, fontWeight: 500, marginLeft: 2 }}>{display}</span>
</>
);
}
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ChartInstance, LegendItem } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useTheme } from '../ThemeContext';
/**
* Minimal visual shape the {@link LegendProps.items} override accepts — just
* the pieces the built-in swatch/label UI needs. The canonical
* {@link LegendItem} (re-exported from `@wick-charts/core`) carries full
* identity plus `toggle`/`isolate` closures; those aren't meaningful when a
* consumer hands in a pre-baked, non-interactive legend.
*/
export interface LegendItemOverride {
label: string;
color: string;
}
/**
* Legend interaction mode.
* - `'toggle'` — click toggles the clicked item on/off (default).
* - `'isolate'` — click shows only that item; click again shows all.
* - `'solo'` — **@deprecated** alias for `'isolate'`, kept for back-compat.
*/
export type LegendMode = 'toggle' | 'isolate' | 'solo';
/** Context passed to the {@link Legend} render-prop. */
export interface LegendRenderContext {
readonly items: readonly LegendItem[];
}
export interface LegendProps {
/**
* Static override for auto-detected items. Renders a non-interactive legend
* with just swatch + label. Ignored when {@link children} is a render-prop.
*/
items?: LegendItemOverride[];
/** Layout position. Default: 'bottom'. */
position?: 'bottom' | 'right';
/** Click behavior for the built-in UI. Default: `'toggle'`. Ignored when {@link children} is provided. */
mode?: LegendMode;
/**
* Render-prop escape hatch. Receives the computed `items` (each carrying
* its own `toggle()` / `isolate()` closures) and fully replaces the
* built-in flex row / column. Callers can filter, reorder, and re-style
* without reimplementing visibility wiring.
*/
children?: (ctx: LegendRenderContext) => ReactNode;
}
interface BuildArgs {
chart: ChartInstance;
isolatedIdRef: { current: string | null };
setIsolatedId: (v: string | null) => void;
}
function buildLegendItems({ chart, isolatedIdRef, setIsolatedId }: BuildArgs): LegendItem[] {
const items: LegendItem[] = [];
const seriesIds = chart.getSeriesIds();
for (const seriesId of seriesIds) {
const layers = chart.getSeriesLayers(seriesId);
if (layers) {
const baseLabel = chart.getSeriesLabel(seriesId);
for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
const id = `${seriesId}_layer${layerIndex}`;
const visible = chart.isSeriesVisible(seriesId) && chart.isLayerVisible(seriesId, layerIndex);
items.push(
makeItem({
id,
seriesId,
layerIndex,
label: baseLabel ? `${baseLabel} ${layerIndex + 1}` : `Series ${layerIndex + 1}`,
color: layers[layerIndex].color,
isDisabled: !visible,
chart,
isolatedIdRef,
setIsolatedId,
}),
);
}
} else {
const color = chart.getSeriesColor(seriesId);
if (!color) continue;
const label = chart.getSeriesLabel(seriesId);
const visible = chart.isSeriesVisible(seriesId);
items.push(
makeItem({
id: seriesId,
seriesId,
layerIndex: undefined,
label: label ?? 'Series',
color,
isDisabled: !visible,
chart,
isolatedIdRef,
setIsolatedId,
}),
);
}
}
return items;
}
interface MakeItemArgs {
id: string;
seriesId: string;
layerIndex: number | undefined;
label: string;
color: string;
isDisabled: boolean;
chart: ChartInstance;
isolatedIdRef: { current: string | null };
setIsolatedId: (v: string | null) => void;
}
function makeItem(args: MakeItemArgs): LegendItem {
const { id, seriesId, layerIndex, label, color, isDisabled, chart, isolatedIdRef, setIsolatedId } = args;
const toggle = () => {
if (layerIndex !== undefined) {
chart.setLayerVisible(seriesId, layerIndex, !chart.isLayerVisible(seriesId, layerIndex));
} else {
chart.setSeriesVisible(seriesId, !chart.isSeriesVisible(seriesId));
}
};
const isolate = () => {
if (isolatedIdRef.current === id) {
chart.batch(() => {
for (const sid of chart.getSeriesIds()) {
chart.setSeriesVisible(sid, true);
const layers = chart.getSeriesLayers(sid);
if (layers) {
for (let i = 0; i < layers.length; i++) chart.setLayerVisible(sid, i, true);
}
}
});
// Mutate the ref synchronously so a back-to-back second `isolate()`
// sees the unisolated state even before React's re-render commits.
isolatedIdRef.current = null;
setIsolatedId(null);
return;
}
chart.batch(() => {
for (const sid of chart.getSeriesIds()) {
const layers = chart.getSeriesLayers(sid);
if (layers) {
chart.setSeriesVisible(sid, sid === seriesId);
for (let i = 0; i < layers.length; i++) {
chart.setLayerVisible(sid, i, sid === seriesId && i === layerIndex);
}
} else {
chart.setSeriesVisible(sid, sid === id);
}
}
});
isolatedIdRef.current = id;
setIsolatedId(id);
};
return { id, seriesId, layerIndex, label, color, isDisabled, toggle, isolate };
}
export function Legend({ items, position = 'bottom', mode = 'toggle', children }: LegendProps) {
const chart = useChartInstance();
const theme = useTheme();
const [isolatedId, setIsolatedId] = useState<string | null>(null);
const [bumpSignal, setBumpSignal] = useState(0);
// Closure bound to every LegendItem.isolate() reads live state through this
// ref, not the captured-at-render value, so a second click correctly sees
// `isolatedId` even when the component hasn't re-rendered yet.
const isolatedIdRef = useRef(isolatedId);
isolatedIdRef.current = isolatedId;
useLayoutEffect(() => {
const onOverlayChange = () => setBumpSignal((n) => n + 1);
const onSeriesChange = () => setIsolatedId(null);
chart.on('overlayChange', onOverlayChange);
chart.on('seriesChange', onSeriesChange);
// Catch-up: a sibling series' layout effect may have registered data in
// the same commit. Bump so the next synchronous render picks it up.
if (chart.getSeriesIds().length > 0) setBumpSignal((n) => n + 1);
return () => {
chart.off('overlayChange', onOverlayChange);
chart.off('seriesChange', onSeriesChange);
};
}, [chart]);
const legendItems = useMemo(
() => buildLegendItems({ chart, isolatedIdRef, setIsolatedId }),
// biome-ignore lint/correctness/useExhaustiveDependencies: `bumpSignal` is the subscription signal; isolatedId triggers the opacity-only refresh
[chart, isolatedId, bumpSignal],
);
const isRight = position === 'right';
const containerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: isRight ? 'column' : 'row',
flexWrap: 'wrap',
gap: isRight ? 6 : 14,
padding: isRight ? '8px 6px' : '6px 8px',
alignItems: isRight ? 'flex-start' : 'center',
justifyContent: isRight ? 'flex-start' : 'center',
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.axisFontSize,
color: theme.axis.textColor,
pointerEvents: 'auto',
flexShrink: 0,
};
if (children) {
if (legendItems.length === 0) return null;
return (
<div data-legend={position} style={containerStyle}>
{children({ items: legendItems })}
</div>
);
}
if (items) {
if (items.length === 0) return null;
return (
<div data-legend={position} style={containerStyle}>
{items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static override — caller chose the order
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, userSelect: 'none' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: item.color, flexShrink: 0 }} />
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
</div>
))}
</div>
);
}
if (legendItems.length === 0) return null;
const handleClick = (item: LegendItem) => {
if (mode === 'isolate' || mode === 'solo') item.isolate();
else item.toggle();
};
return (
<div data-legend={position} style={containerStyle}>
{legendItems.map((item) => (
<div
key={item.id}
onClick={() => handleClick(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
opacity: item.isDisabled ? 0.35 : 1,
transition: 'opacity 0.15s ease',
userSelect: 'none',
}}
>
<span style={{ width: 8, height: 8, borderRadius: 2, background: item.color, flexShrink: 0 }} />
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
</div>
))}
</div>
);
}
import { type CSSProperties, useEffect, useMemo, useRef } from 'react';
export interface NumberFlowProps {
value: number;
/**
* Value-to-string formatter. Defaults to the current locale's
* `Intl.NumberFormat` when omitted. Pass the shared `formatCompact` /
* `formatPriceAdaptive` helpers or your own function to customize.
*
* `Intl.NumberFormatOptions` is also accepted (legacy) — it's routed
* through the built-in `Intl.NumberFormat` for back-compat with callers
* from before this prop was a function.
*/
format?: ((value: number) => string) | Intl.NumberFormatOptions;
locale?: string;
spinDuration?: number;
className?: string;
style?: CSSProperties;
}
const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
interface CharPart {
type: 'digit' | 'symbol';
value: string;
}
function decompose(formatted: string): CharPart[] {
const parts: CharPart[] = [];
for (const char of formatted) {
if (char >= '0' && char <= '9') {
parts.push({ type: 'digit', value: char });
} else {
parts.push({ type: 'symbol', value: char });
}
}
return parts;
}
export function NumberFlow({ value, format, locale = 'en-US', spinDuration = 350, className, style }: NumberFlowProps) {
const effectiveFormat = useMemo<(v: number) => string>(() => {
if (typeof format === 'function') return format;
const nf = new Intl.NumberFormat(locale, typeof format === 'object' ? format : undefined);
return (v: number) => nf.format(v);
}, [format, locale]);
const formatted = effectiveFormat(value);
const parts = decompose(formatted);
return (
<span
className={className}
style={{
display: 'inline-flex',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.2,
...style,
}}
>
{parts.map((part, i) =>
part.type === 'digit' ? (
<DigitSlot key={`d${i}`} digit={parseInt(part.value, 10)} duration={spinDuration} />
) : (
<span key={`s${i}`} style={{ display: 'inline-block' }}>
{part.value}
</span>
),
)}
</span>
);
}
interface DigitSlotProps {
digit: number;
duration: number;
}
function DigitSlot({ digit, duration }: DigitSlotProps) {
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
}, []);
return (
<span
style={{
display: 'inline-block',
height: '1.2em',
overflow: 'hidden',
position: 'relative',
}}
>
<span
style={{
display: 'flex',
flexDirection: 'column',
transform: `translateY(${-digit * 1.2}em)`,
transition: mountedRef.current ? `transform ${duration}ms cubic-bezier(0.16, 1, 0.3, 1)` : 'none',
}}
>
{DIGITS.map((d) => (
<span
key={d}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '1.2em',
}}
>
{d}
</span>
))}
</span>
</span>
);
}
import { type ReactNode, useLayoutEffect, useState } from 'react';
import { type ChartInstance, type SliceInfo, type ValueFormatter, formatCompact } from '@wick-charts/core';
import { useChartInstance } from '../context';
/**
* Legend row content.
*
* - `'value'` — only the absolute value (e.g. `25`).
* - `'percent'` — only the percentage (e.g. `25.0%`).
* - `'both'` (default) — value + percent side-by-side; value is bold, percent dimmed.
*/
export type PieLegendMode = 'value' | 'percent' | 'both';
/**
* Where the legend sits relative to the pie canvas.
*
* - `'bottom'` (default) — flex sibling below the canvas. Matches the time-series `<Legend>` layout.
* - `'right'` — flex sibling on the right of the canvas.
* - `'overlay'` — absolute overlay on top of the canvas. Back-compat escape hatch for callers
* that were relying on the old positioning; stacks with any `<Title>` at the top-left and can
* collide with it, so use sparingly.
*/
export type PieLegendPosition = 'bottom' | 'right' | 'overlay';
/** Context passed to the {@link PieLegend} render-prop. */
export interface PieLegendRenderContext {
readonly slices: readonly SliceInfo[];
readonly mode: PieLegendMode;
readonly format: ValueFormatter;
}
export interface PieLegendProps {
/**
* Owning series id. **Optional** — when omitted, the first visible pie
* series is picked.
*/
seriesId?: string;
/** Default: `'both'`. See {@link PieLegendMode}. */
mode?: PieLegendMode;
/** Custom formatter for the absolute slice value. Default: shared `formatCompact`. */
format?: ValueFormatter;
/** Layout placement. Default: `'bottom'`. See {@link PieLegendPosition}. */
position?: PieLegendPosition;
/** Render-prop escape hatch. Receives slices + mode + format, replaces default UI. */
children?: (ctx: PieLegendRenderContext) => ReactNode;
}
function resolvePieSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
if (explicit !== undefined) return explicit;
const pies = chart.getSeriesIdsByType('pie', { visibleOnly: true });
return pies.length > 0 ? pies[0] : null;
}
export function PieLegend({ seriesId, mode: modeProp, format, position, children }: PieLegendProps) {
const mode: PieLegendMode = modeProp ?? 'both';
const resolvedPosition: PieLegendPosition = position ?? 'bottom';
const formatter: ValueFormatter = format ?? formatCompact;
const chart = useChartInstance();
const theme = chart.getTheme();
const [, setBumpSignal] = useState(0);
useLayoutEffect(() => {
const handler = () => setBumpSignal((n) => n + 1);
chart.on('overlayChange', handler);
if (chart.getSeriesIds().length > 0) handler();
return () => {
chart.off('overlayChange', handler);
};
}, [chart]);
const resolvedId = resolvePieSeriesId(chart, seriesId);
const slices = resolvedId !== null ? chart.getSliceInfo(resolvedId) : null;
if (!slices || slices.length === 0) return null;
if (children) return <>{children({ slices, mode, format: formatter })}</>;
// When hoisted as a flex sibling (`bottom` / `right`), the container already
// reserves the legend's box. The extra 8px × 12px block padding only applies
// in `overlay` mode where the legend floats above the canvas and needs to
// breathe away from the edges.
const isOverlay = resolvedPosition === 'overlay';
return (
<div
data-chart-pie-legend=""
data-chart-pie-legend-position={resolvedPosition}
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: isOverlay ? '8px 12px' : '6px 10px',
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.fontSize,
color: theme.tooltip.textColor,
pointerEvents: 'auto',
}}
>
{slices.map((slice, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: slice index is stable within a render
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: slice.color,
flexShrink: 0,
}}
/>
<span style={{ flex: 1, opacity: 0.8 }}>{slice.label}</span>
{(mode === 'value' || mode === 'both') && (
<span
style={{
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
// In 'value'-only mode the cell is the primary number and wants
// a wider reserved slot; in 'both' it's followed by the percent
// so keep it tight.
minWidth: mode === 'value' ? 40 : undefined,
textAlign: 'right',
}}
>
{formatter(slice.value)}
</span>
)}
{(mode === 'percent' || mode === 'both') && (
<span
style={{
// In 'percent' mode the percent IS the value → bold at full
// opacity. In 'both' it's a secondary reading next to the
// absolute value → dim + smaller.
opacity: mode === 'percent' ? 1 : 0.5,
fontWeight: mode === 'percent' ? 600 : 400,
fontSize: mode === 'percent' ? theme.typography.fontSize : theme.typography.axisFontSize,
fontVariantNumeric: 'tabular-nums',
minWidth: 40,
textAlign: 'right',
}}
>
{slice.percent.toFixed(1)}%
</span>
)}
</div>
))}
</div>
);
}
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
type ChartInstance,
type HoverInfo,
type ValueFormatter,
computeTooltipPosition,
formatCompact,
} from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useCrosshairPosition } from '../store-bridge';
/** Context passed to the {@link PieTooltip} render-prop. */
export interface PieTooltipRenderContext {
readonly info: HoverInfo;
readonly format: ValueFormatter;
}
export interface PieTooltipProps {
/**
* Owning series id. **Optional** — when omitted, the first visible pie
* series is picked.
*/
seriesId?: string;
/** Custom formatter for the slice value. Default: shared `formatCompact`. */
format?: ValueFormatter;
/** Render-prop escape hatch — receives hover info + format, replaces default UI. */
children?: (ctx: PieTooltipRenderContext) => ReactNode;
}
function resolvePieSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
if (explicit !== undefined) return explicit;
const pies = chart.getSeriesIdsByType('pie', { visibleOnly: true });
return pies.length > 0 ? pies[0] : null;
}
const DEFAULT_TOOLTIP_WIDTH = 160;
const DEFAULT_TOOLTIP_HEIGHT = 70;
/** Tooltip for pie/donut charts. Shows hovered slice label, value, and percentage. */
export function PieTooltip({ seriesId, format = formatCompact, children }: PieTooltipProps) {
const chart = useChartInstance();
const crosshair = useCrosshairPosition(chart);
const [, setBumpSignal] = useState(0);
useLayoutEffect(() => {
const handler = () => setBumpSignal((n) => n + 1);
chart.on('overlayChange', handler);
return () => {
chart.off('overlayChange', handler);
};
}, [chart]);
const resolvedId = resolvePieSeriesId(chart, seriesId);
const info = resolvedId !== null ? chart.getHoverInfo(resolvedId) : null;
if (!info || !crosshair) return null;
const theme = chart.getTheme();
const mediaSize = chart.getMediaSize();
if (children) {
return (
<CustomPieTooltip
x={crosshair.mediaX}
y={crosshair.mediaY}
chartWidth={mediaSize.width}
chartHeight={mediaSize.height}
>
{children({ info, format })}
</CustomPieTooltip>
);
}
const { left, top } = computeTooltipPosition({
x: crosshair.mediaX,
y: crosshair.mediaY,
chartWidth: mediaSize.width,
chartHeight: mediaSize.height,
tooltipWidth: DEFAULT_TOOLTIP_WIDTH,
tooltipHeight: DEFAULT_TOOLTIP_HEIGHT,
offsetX: 16,
offsetY: 16,
});
return (
<div
style={{
position: 'absolute',
left,
top,
pointerEvents: 'none',
zIndex: 10,
background: theme.tooltip.background,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: `1px solid ${theme.tooltip.borderColor}`,
borderRadius: 8,
padding: '10px 14px',
boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
fontSize: theme.typography.fontSize,
fontFamily: theme.typography.fontFamily,
color: theme.tooltip.textColor,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: info.color,
flexShrink: 0,
}}
/>
<span style={{ fontWeight: 600 }}>{info.label}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
<span style={{ opacity: 0.6 }}>{format(info.value)}</span>
<span style={{ fontWeight: 600 }}>{info.percent.toFixed(1)}%</span>
</div>
</div>
);
}
// Custom slot content has unknown dimensions — measure the container, then
// position it. Matches the pattern in `Tooltip` (PR #39) so user-rendered pie
// tooltips flip/clamp correctly near edges instead of overflowing with the
// hardcoded 160×70 defaults.
function CustomPieTooltip({
x,
y,
chartWidth,
chartHeight,
children,
}: {
x: number;
y: number;
chartWidth: number;
chartHeight: number;
children: ReactNode;
}) {
const nodeRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
const node = nodeRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const box = entries[0]?.contentRect;
if (!box) return;
setSize((prev) =>
prev && prev.width === box.width && prev.height === box.height
? prev
: { width: box.width, height: box.height },
);
});
ro.observe(node);
return () => ro.disconnect();
}, []);
const position = size
? computeTooltipPosition({
x,
y,
chartWidth,
chartHeight,
tooltipWidth: size.width,
tooltipHeight: size.height,
offsetX: 16,
offsetY: 16,
})
: { left: 0, top: 0 };
return (
<div
ref={nodeRef}
data-measured={size ? 'true' : 'false'}
style={{
position: 'absolute',
left: position.left,
top: position.top,
pointerEvents: 'none',
zIndex: 10,
width: 'max-content',
maxWidth: chartWidth,
boxSizing: 'border-box',
// Hide until the first measurement so the user never sees a paint
// with out-of-bounds position.
visibility: size ? 'visible' : 'hidden',
}}
>
{children}
</div>
);
}
import { type CSSProperties, useMemo } from 'react';
import { type ChartTheme, type TimePoint, formatCompact } from '@wick-charts/core';
import { BarSeries } from '../BarSeries';
import { ChartContainer } from '../ChartContainer';
import { LineSeries } from '../LineSeries';
export type SparklineVariant = 'line' | 'bar';
export type SparklineValuePosition = 'left' | 'right' | 'none';
export interface SparklineProps {
data: TimePoint[];
theme: ChartTheme;
/** 'line' (default) or 'bar' */
variant?: SparklineVariant;
/** Where to show the value label */
valuePosition?: SparklineValuePosition;
/** Custom format for the value */
formatValue?: (value: number) => string;
/** Label text above the value */
label?: string;
/** Sublabel text below the value (defaults to the change %) */
sublabel?: string;
/** Line/bar color override (defaults to theme) */
color?: string;
/** Secondary color for negative bars */
negativeColor?: string;
/** Show area fill under line */
area?: { visible: boolean };
/** @deprecated Use {@link area} instead. */
areaFill?: boolean;
/** Chart width (default: 140) */
width?: number;
/** Overall height (default: 48) */
height?: number;
/** Stroke width in CSS pixels (default: 1.5) */
strokeWidth?: number;
/** Show chart background gradient (default: true) */
gradient?: boolean;
/** Container style override */
style?: CSSProperties;
}
function hexToRgba(color: string, alpha: number): string {
if (color.startsWith('rgba')) return color;
if (!color.startsWith('#')) return color;
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function computeChange(data: TimePoint[]): { value: number; pct: number; positive: boolean } {
if (data.length < 2) return { value: 0, pct: 0, positive: true };
const first = data[0].value;
const last = data[data.length - 1].value;
const diff = last - first;
const pct = first !== 0 ? (diff / first) * 100 : 0;
return { value: diff, pct, positive: diff >= 0 };
}
export function Sparkline({
data,
theme,
variant = 'line',
valuePosition = 'right',
formatValue = formatCompact,
label,
sublabel,
color,
negativeColor,
area,
areaFill,
width = 140,
height = 48,
strokeWidth = 1.5,
gradient = true,
style,
}: SparklineProps) {
// Default area-visible = true. `area` wins if caller passes it; otherwise
// fall back to the deprecated flat `areaFill` flag for backward compatibility.
const areaVisible = area?.visible ?? areaFill ?? true;
const lastValue = data.length > 0 ? data[data.length - 1].value : 0;
const change = useMemo(() => computeChange(data), [data]);
const resolvedColor = color ?? theme.seriesColors[0];
const resolvedNegColor = negativeColor ?? theme.candlestick.downColor;
const changeColor = change.positive ? theme.candlestick.upColor : theme.candlestick.downColor;
const valueBlock = valuePosition !== 'none' && (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 1,
minWidth: 0,
flexShrink: 0,
}}
>
{label && (
<div
style={{
fontSize: theme.typography.axisFontSize,
color: theme.axis.textColor,
lineHeight: 1.2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{label}
</div>
)}
<div
style={{
fontSize: theme.typography.fontSize + 3,
fontWeight: 700,
color: theme.tooltip.textColor,
lineHeight: 1.1,
whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}
>
{formatValue(lastValue)}
</div>
{sublabel !== undefined ? (
<div
style={{
fontSize: theme.typography.axisFontSize - 1,
color: theme.axis.textColor,
lineHeight: 1.2,
whiteSpace: 'nowrap',
}}
>
{sublabel}
</div>
) : (
<div
style={{
fontSize: theme.typography.axisFontSize - 1,
fontWeight: 500,
color: changeColor,
lineHeight: 1.2,
whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}
>
{change.positive ? '+' : ''}
{change.pct.toFixed(1)}%
</div>
)}
</div>
);
const chartBlock = (
<div style={{ width, height, flexShrink: 0, borderRadius: 4, overflow: 'hidden' }}>
<ChartContainer
theme={theme}
axis={{
y: { visible: false, width: 0 },
x: { visible: false, height: 0 },
}}
padding={{ top: 5, right: 0, bottom: 0, left: 0 }}
gradient={gradient}
interactive={false}
grid={{ visible: false }}
>
{variant === 'line' ? (
<LineSeries
data={[data]}
options={{
colors: [resolvedColor],
strokeWidth,
area: { visible: areaVisible },
pulse: false,
stacking: 'off',
}}
/>
) : (
<BarSeries
data={[data]}
options={{
colors: [resolvedColor, resolvedNegColor],
barWidthRatio: 0.7,
stacking: 'off',
}}
/>
)}
</ChartContainer>
</div>
);
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
borderRadius: 8,
background: hexToRgba(theme.tooltip.background, 0.7),
border: `1px solid ${theme.tooltip.borderColor}`,
fontFamily: theme.typography.fontFamily,
...style,
}}
>
{valuePosition === 'left' && valueBlock}
{chartBlock}
{valuePosition === 'right' && valueBlock}
</div>
);
}
import { useLayoutEffect, useRef } from 'react';
import { formatTime } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useVisibleRange } from '../store-bridge';
interface TrackedTick {
opacity: number;
addedAt: number;
fadedAt?: number;
}
export interface TimeAxisProps {
/** Desired number of labels (≥ 2). Overrides chart-level `axis.x.labelCount`. */
labelCount?: number;
/** Minimum pixel gap between adjacent labels (hard floor). Overrides chart-level. */
minLabelSpacing?: number;
}
export function TimeAxis({ labelCount, minLabelSpacing }: TimeAxisProps = {}) {
const chart = useChartInstance();
useVisibleRange(chart); // subscribe to viewport changes so ticks re-render
useLayoutEffect(() => {
chart.setTimeAxisLabelDensity({
labelCount: labelCount ?? null,
minLabelSpacing: minLabelSpacing ?? null,
});
return () => {
chart.setTimeAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
};
}, [chart, labelCount, minLabelSpacing]);
const theme = chart.getTheme();
const dataInterval = chart.getDataInterval();
const { ticks: currentTicks, tickInterval } = chart.timeScale.niceTickValues(dataInterval);
const currentSet = new Set(currentTicks);
// Persistent map: tick value → tracked state
const mapRef = useRef<Map<number, TrackedTick>>(new Map());
const map = mapRef.current;
const now = performance.now();
// Mark current ticks as visible
for (const t of currentTicks) {
if (!map.has(t)) {
map.set(t, { opacity: 1, addedAt: now });
} else {
map.get(t)!.opacity = 1;
}
}
// Mark missing ticks for fade-out
for (const [t, entry] of map) {
if (!currentSet.has(t)) {
if (entry.opacity !== 0) {
entry.opacity = 0;
entry.fadedAt = now;
}
}
}
// Clean up ticks that have finished fading (400ms CSS transition + buffer)
for (const [t, entry] of map) {
if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > 600) {
map.delete(t);
}
}
// Collect all ticks to render (current + fading out)
const allTicks = Array.from(map.entries());
return (
<div
style={{
position: 'absolute',
left: 0,
bottom: 0,
right: chart.yAxisWidth,
height: chart.xAxisHeight,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
}}
>
{allTicks.map(([time, entry]) => {
const x = chart.timeScale.timeToX(time);
return (
<span
key={time}
style={{
position: 'absolute',
left: x,
transform: 'translateX(-50%)',
color: theme.axis.textColor,
fontSize: theme.typography.axisFontSize,
fontFamily: theme.typography.fontFamily,
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: entry.opacity,
transition: 'opacity 0.3s ease',
willChange: 'opacity',
}}
>
{formatTime(time, tickInterval)}
</span>
);
})}
</div>
);
}
import type { CSSProperties, ReactNode } from 'react';
import { useTheme } from '../ThemeContext';
/** Props for the {@link Title} component. */
export interface TitleProps {
/** Primary label (e.g. "BTC/USD"). */
children?: ReactNode;
/**
* Secondary label rendered in a muted colour next to the primary one (e.g.
* "Live Candlestick", "1m", series count).
*/
sub?: ReactNode;
/** Extra styles merged onto the flex row. */
style?: CSSProperties;
}
/**
* Chart title / subtitle bar rendered as a flex row above the chart canvas
* (above {@link InfoBar} when both are present). Hoisted out of the
* overlay layer by {@link ChartContainer}, so browser flex layout reserves
* its height and ResizeObserver drives a Y-range recompute.
*
* Place it at the top of a chart's children — its slot in the DOM is
* determined by `ChartContainer`, not by source order:
* ```tsx
* <ChartContainer>
* <Title sub="Live Candlestick">BTC/USD</Title>
* <InfoBar />
* <CandlestickSeries data={data} />
* ...
* </ChartContainer>
* ```
*/
export function Title({ children, sub, style }: TitleProps) {
const theme = useTheme();
return (
<div
data-chart-title=""
style={{
display: 'flex',
alignItems: 'baseline',
gap: 6,
padding: '6px 8px 4px',
flexShrink: 0,
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.fontSize,
fontWeight: 600,
color: theme.tooltip.textColor,
pointerEvents: 'none',
...style,
}}
>
{children != null && children !== false && <span>{children}</span>}
{sub != null && sub !== false && (
<span style={{ fontWeight: 400, color: theme.axis.textColor, fontSize: theme.typography.axisFontSize }}>
{sub}
</span>
)}
</div>
);
}
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
type ChartTheme,
type OHLCData,
type SeriesSnapshot,
type TimePoint,
type TooltipFormatter,
buildHoverSnapshots,
computeTooltipPosition,
formatCompact,
formatDate,
formatPriceAdaptive,
formatTime,
} from '@wick-charts/core';
export { computeTooltipPosition } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useCrosshairPosition } from '../store-bridge';
/** Sort order for multi-series tooltip values. */
export type TooltipSort = 'none' | 'asc' | 'desc';
/** Context passed to the {@link Tooltip} render-prop. */
export interface TooltipRenderContext {
readonly snapshots: readonly SeriesSnapshot[];
/** Crosshair timestamp — the tooltip is hover-only, so this is always a real hover time. */
readonly time: number;
}
/**
* Props for the {@link Tooltip} component.
* Renders the built-in floating glass panel by default. Pass a render-prop
* child to replace its *contents* — the positioned container (with flip/clamp)
* stays.
*/
export interface TooltipProps {
/** Sort order for line values (default: 'none'). */
sort?: TooltipSort;
/**
* Custom formatter for every displayed number in the default UI. Called per
* row with the field hint (`'open' | 'high' | 'low' | 'close' | 'volume' | 'value'`).
* Defaults: adaptive precision for ohlc/value, compact (K/M/B/T) for volume.
* Ignored when {@link children} is a render-prop.
*/
format?: TooltipFormatter;
/**
* Render-prop escape hatch. Receives the hover snapshots and replaces the
* built-in panel contents. The floating container (positioning, blur glass,
* clamping) is preserved — use
* [`computeTooltipPosition`](../../core/src/tooltip-position.ts) directly if
* you need your own container.
*/
children?: (ctx: TooltipRenderContext) => ReactNode;
}
/** Default tooltip formatter — adaptive precision + compact volumes. */
const defaultTooltipFormat: TooltipFormatter = (v, field) =>
field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v);
/**
* Floating near-cursor glass tooltip that appears while hovering the chart.
*
* Hover-only: without a crosshair position, the component renders `null`.
* The companion {@link InfoBar} shows last-known values when no hover is active.
*/
export function Tooltip({ sort = 'none', format = defaultTooltipFormat, children }: TooltipProps) {
const chart = useChartInstance();
const crosshair = useCrosshairPosition(chart);
const [, bump] = useState(0);
useLayoutEffect(() => {
const onOverlayChange = () => bump((n) => n + 1);
chart.on('overlayChange', onOverlayChange);
if (chart.getSeriesIds().length > 0) bump((n) => n + 1);
return () => {
chart.off('overlayChange', onOverlayChange);
};
}, [chart]);
if (!crosshair) return null;
const snapshots = buildHoverSnapshots(chart, { time: crosshair.time, sort, cacheKey: 'tooltip' });
if (snapshots.length === 0) return null;
const theme = chart.getTheme();
const dataInterval = chart.getDataInterval();
const mediaSize = chart.getMediaSize();
const chartWidth = mediaSize.width - chart.yAxisWidth;
const chartHeight = mediaSize.height - chart.xAxisHeight;
if (children) {
return (
<CustomFloatingTooltip
x={crosshair.mediaX}
y={crosshair.mediaY}
chartWidth={chartWidth}
chartHeight={chartHeight}
theme={theme}
>
{/* `crosshair.time` is the semantic truth — snapshots[0].data.time
shifts with `sort` and, for ragged multi-layer data, disagrees
with the actual hover moment. */}
{children({ snapshots, time: crosshair.time })}
</CustomFloatingTooltip>
);
}
return (
<FloatingTooltip
snapshots={snapshots}
displayTime={crosshair.time}
x={crosshair.mediaX}
y={crosshair.mediaY}
chartWidth={chartWidth}
chartHeight={chartHeight}
theme={theme}
dataInterval={dataInterval}
format={format}
/>
);
}
function CustomFloatingTooltip({
x,
y,
chartWidth,
chartHeight,
theme,
children,
}: {
x: number;
y: number;
chartWidth: number;
chartHeight: number;
theme: ChartTheme;
children: ReactNode;
}) {
// Custom content has unknown dimensions until the first paint — measure the
// container, then position it. Hide with `visibility: hidden` on the
// pre-measured frame so the user never sees an un-clamped paint.
const nodeRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
const node = nodeRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const box = entries[0]?.contentRect;
if (!box) return;
setSize((prev) =>
prev && prev.width === box.width && prev.height === box.height
? prev
: { width: box.width, height: box.height },
);
});
ro.observe(node);
return () => ro.disconnect();
}, []);
const position = size
? computeTooltipPosition({ x, y, chartWidth, chartHeight, tooltipWidth: size.width, tooltipHeight: size.height })
: { left: 0, top: 0 };
return (
<div
ref={nodeRef}
data-measured={size ? 'true' : 'false'}
style={{
position: 'absolute',
left: position.left,
top: position.top,
pointerEvents: 'none',
background: theme.tooltip.background,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: `1px solid ${theme.tooltip.borderColor}`,
borderRadius: 8,
padding: '10px 14px',
boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.tooltipFontSize,
fontVariantNumeric: 'tabular-nums',
color: theme.tooltip.textColor,
width: 'max-content',
maxWidth: chartWidth,
boxSizing: 'border-box',
zIndex: 10,
visibility: size ? 'visible' : 'hidden',
}}
>
{children}
</div>
);
}
function FloatingTooltip({
snapshots,
displayTime,
x,
y,
chartWidth,
chartHeight,
theme,
dataInterval,
format,
}: {
snapshots: readonly SeriesSnapshot[];
displayTime: number;
x: number;
y: number;
chartWidth: number;
chartHeight: number;
theme: ChartTheme;
dataInterval: number;
format: TooltipFormatter;
}) {
const hasOHLC = snapshots.some((s) => 'open' in s.data);
const lineCount = snapshots.filter((s) => !('open' in s.data)).length;
const tooltipWidth = 160;
const tooltipHeight = hasOHLC ? 140 : 40 + lineCount * 22;
const { left, top } = computeTooltipPosition({ x, y, chartWidth, chartHeight, tooltipWidth, tooltipHeight });
const bg = theme.tooltip.background;
const border = theme.tooltip.borderColor;
const shadow = '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)';
return (
<div
style={{
position: 'absolute',
left,
top,
pointerEvents: 'none',
background: bg,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: `1px solid ${border}`,
borderRadius: 8,
padding: '10px 14px',
boxShadow: shadow,
fontSize: theme.typography.tooltipFontSize,
fontFamily: theme.typography.fontFamily,
fontVariantNumeric: 'tabular-nums',
color: theme.tooltip.textColor,
// Fix the rendered width to the value `computeTooltipPosition` assumes
// so content growth (e.g. long labels) can't push the tooltip past the
// clamp and back out of the plot area.
width: tooltipWidth,
boxSizing: 'border-box',
zIndex: 10,
transition: 'opacity 0.15s ease',
}}
>
{/* Time header */}
<div
style={{
fontSize: theme.typography.axisFontSize,
color: theme.axis.textColor,
marginBottom: 8,
paddingBottom: 6,
borderBottom: `1px solid ${border}`,
letterSpacing: '0.02em',
}}
>
{formatDate(displayTime)} {formatTime(displayTime, dataInterval)}
</div>
{snapshots.map((s) => {
const isOHLC = 'open' in s.data;
if (isOHLC) {
const ohlc = s.data as OHLCData;
const isUp = ohlc.close >= ohlc.open;
const upColor = theme.candlestick.upColor;
const downColor = theme.candlestick.downColor;
const valColor = isUp ? upColor : downColor;
return (
<div key={s.id} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px' }}>
<TooltipRow label="Open" color={valColor} display={format(ohlc.open, 'open')} />
<TooltipRow label="High" color={valColor} display={format(ohlc.high, 'high')} />
<TooltipRow label="Low" color={valColor} display={format(ohlc.low, 'low')} />
<TooltipRow label="Close" color={valColor} display={format(ohlc.close, 'close')} />
{ohlc.volume != null && (
<TooltipRow label="Volume" color={theme.tooltip.textColor} display={format(ohlc.volume, 'volume')} />
)}
</div>
);
}
const line = s.data as TimePoint;
return (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: s.color,
flexShrink: 0,
}}
/>
<span style={{ opacity: 0.6, flex: 1 }}>{s.label ?? 'Value'}</span>
<span style={{ fontWeight: 600, color: s.color }}>{format(line.value, 'value')}</span>
</div>
);
})}
</div>
);
}
function TooltipRow({ label, color, display }: { label: string; color: string; display: string }) {
return (
<>
<span style={{ opacity: 0.5 }}>{label}</span>
<span style={{ fontWeight: 600, color, textAlign: 'right' }}>{display}</span>
</>
);
}
import { useLayoutEffect, useRef } from 'react';
import type { ValueFormatter } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { useYRange } from '../store-bridge';
interface TrackedTick {
opacity: number;
addedAt: number;
fadedAt?: number;
}
export interface YAxisProps {
/**
* Custom tick-label formatter. When supplied, overrides the built-in
* range-adaptive formatter for this axis.
*/
format?: ValueFormatter;
/**
* Desired number of labels (≥ 2). Overrides any chart-level `axis.y.labelCount`.
* Realized count may differ ±1 after the 1-2-5 snap.
*/
labelCount?: number;
/** Minimum pixel gap between adjacent labels (hard floor). Overrides chart-level. */
minLabelSpacing?: number;
}
export function YAxis({ format, labelCount, minLabelSpacing }: YAxisProps = {}) {
const chart = useChartInstance();
useYRange(chart); // subscribe to viewport changes so ticks re-render
// Route the prop through yScale so the *same* formatter drives every
// surface that reads `yScale.formatY()` (Crosshair, YLabel fallback).
useLayoutEffect(() => {
chart.yScale.setFormat(format ?? null);
return () => chart.yScale.setFormat(null);
}, [chart, format]);
useLayoutEffect(() => {
chart.setYAxisLabelDensity({
labelCount: labelCount ?? null,
minLabelSpacing: minLabelSpacing ?? null,
});
return () => {
chart.setYAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
};
}, [chart, labelCount, minLabelSpacing]);
const theme = chart.getTheme();
const currentTicks = chart.yScale.niceTickValues();
const currentSet = new Set(currentTicks);
const mapRef = useRef<Map<number, TrackedTick>>(new Map());
const map = mapRef.current;
const now = performance.now();
for (const p of currentTicks) {
if (!map.has(p)) {
map.set(p, { opacity: 1, addedAt: now });
} else {
map.get(p)!.opacity = 1;
}
}
for (const [p, entry] of map) {
if (!currentSet.has(p)) {
if (entry.opacity !== 0) {
entry.opacity = 0;
entry.fadedAt = now;
}
}
}
for (const [p, entry] of map) {
if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > 600) {
map.delete(p);
}
}
const allTicks = Array.from(map.entries());
return (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: chart.xAxisHeight,
width: chart.yAxisWidth,
pointerEvents: 'none',
}}
>
{allTicks.map(([price, entry]) => {
const y = chart.yScale.valueToY(price);
return (
<span
key={price}
style={{
position: 'absolute',
right: 8,
top: y,
transform: 'translateY(-50%)',
color: theme.axis.textColor,
fontSize: theme.typography.axisFontSize,
fontFamily: theme.typography.fontFamily,
fontVariantNumeric: 'tabular-nums',
userSelect: 'none',
opacity: entry.opacity,
transition: 'opacity 0.3s ease',
willChange: 'opacity',
}}
>
{chart.yScale.formatY(price)}
</span>
);
})}
</div>
);
}
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import type { ChartInstance, ValueFormatter } from '@wick-charts/core';
import { useChartInstance } from '../context';
import { NumberFlow } from './NumberFlow';
/** Direction of the current value vs. previous close. Drives the badge color in the default UI. */
export type YLabelDirection = 'up' | 'down' | 'neutral';
/** Context passed to the {@link YLabel} render-prop. */
export interface YLabelRenderContext {
readonly value: number;
/** Pixel Y of the badge anchor (already account for current viewport). */
readonly y: number;
/** Final background color chosen by the built-in UI — handy if you want to match the dashed line accent. */
readonly bgColor: string;
/** `true` while the chart is tracking a live last point (still mutating). */
readonly isLive: boolean;
readonly direction: YLabelDirection;
readonly format: ValueFormatter;
}
export interface YLabelProps {
/**
* Owning series id. **Optional** — when omitted, the first visible
* single-layer time series is picked, falling back to the first visible
* multi-layer time series. `null` (no compatible series) renders nothing.
*/
seriesId?: string;
/** Override badge color (e.g. line color). If not set, uses up/down/neutral from theme. */
color?: string;
/**
* Custom formatter. Routed through NumberFlow as its `format` prop so the
* digit-by-digit animation still plays on the output string — NumberFlow
* animates whichever characters the formatter returns.
*/
format?: ValueFormatter;
/**
* Render-prop escape hatch. Receives the resolved value, pixel position, and
* direction metadata. Replaces the built-in badge + dashed line entirely.
*/
children?: (ctx: YLabelRenderContext) => ReactNode;
}
function resolveSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
if (explicit !== undefined) return explicit;
const singleLayer = chart.getSeriesIdsByType('time', { visibleOnly: true, singleLayerOnly: true });
if (singleLayer.length > 0) return singleLayer[0];
const anyTime = chart.getSeriesIdsByType('time', { visibleOnly: true });
return anyTime.length > 0 ? anyTime[0] : null;
}
export function YLabel({ seriesId, color, format, children }: YLabelProps) {
const chart = useChartInstance();
// Notify chart that YLabel is present (affects right padding).
useEffect(() => {
chart.setYLabel(true);
return () => chart.setYLabel(false);
}, [chart]);
// Single subscription covering data/visibility/theme/options changes, plus
// viewportChange for pixel-Y drift on pan/zoom where the value is unchanged
// but the badge must move.
const [, setBumpSignal] = useState(0);
useLayoutEffect(() => {
const onChange = () => setBumpSignal((n) => n + 1);
chart.on('overlayChange', onChange);
chart.on('viewportChange', onChange);
if (chart.getSeriesIds().length > 0) setBumpSignal((n) => n + 1);
return () => {
chart.off('overlayChange', onChange);
chart.off('viewportChange', onChange);
};
}, [chart]);
const resolvedId = resolveSeriesId(chart, seriesId);
const last = resolvedId !== null ? chart.getStackedLastValue(resolvedId) : null;
const previousClose = resolvedId !== null ? chart.getPreviousClose(resolvedId) : null;
const yRange = chart.yScale.getRange();
const range = yRange.max - yRange.min;
const fractionDigits = range < 0.1 ? 6 : range < 10 ? 4 : range < 1000 ? 2 : 0;
// Build the fallback range-adaptive Intl formatter before the early return
// so this hook call can't be skipped on subsequent renders (Rules of Hooks).
const effectiveFormat = useMemo<ValueFormatter>(() => {
if (format) return format;
const nf = new Intl.NumberFormat('en-US', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
useGrouping: false,
});
return (v: number) => nf.format(v);
}, [format, fractionDigits]);
if (!last || resolvedId === null) return null;
const { value, isLive } = last;
const theme = chart.getTheme();
const y = chart.yScale.valueToY(value);
const direction: YLabelDirection =
previousClose === null ? 'neutral' : value > previousClose ? 'up' : value < previousClose ? 'down' : 'neutral';
let bgColor: string;
if (!isLive) {
bgColor = theme.axis.textColor;
} else if (color) {
bgColor = color;
} else {
bgColor =
direction === 'up'
? theme.yLabel.upBackground
: direction === 'down'
? theme.yLabel.downBackground
: theme.yLabel.neutralBackground;
}
if (children) {
return <>{children({ value, y, bgColor, isLive, direction, format: effectiveFormat })}</>;
}
return (
<>
<div
style={{
position: 'absolute',
left: 0,
right: chart.yAxisWidth,
top: y,
height: 0,
borderTop: `1px dashed ${bgColor}`,
opacity: 0.5,
pointerEvents: 'none',
zIndex: 2,
}}
/>
<div
style={{
position: 'absolute',
right: 4,
top: y,
transform: 'translateY(-50%)',
pointerEvents: 'auto',
zIndex: 3,
background: bgColor,
color: theme.yLabel.textColor,
fontSize: theme.typography.yFontSize,
fontFamily: theme.typography.fontFamily,
padding: '3px 8px',
borderRadius: 3,
whiteSpace: 'nowrap',
transition: 'background-color 0.3s ease',
}}
>
<NumberFlow value={value} format={effectiveFormat} spinDuration={350} />
</div>
</>
);
}
+28
-3
{
"name": "@wick-charts/react",
"version": "0.2.2",
"version": "0.2.3",
"description": "High-performance canvas timeseries charts for React — candlestick, line, bar, pie. Tree-shakeable, zero runtime deps.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/mo4islona/wick-charts.git",
"directory": "packages/react"
},
"homepage": "https://mo4islona.github.io/wick-charts/",
"bugs": "https://github.com/mo4islona/wick-charts/issues",
"keywords": [
"charts",
"charting",
"canvas",
"candlestick",
"line-chart",
"bar-chart",
"pie-chart",
"timeseries",
"react",
"visualization"
],
"type": "module",

@@ -17,3 +38,7 @@ "sideEffects": false,

"files": [
"dist"
"dist",
"src",
"LICENSE",
"!src/__tests__",
"!src/**/*.test.*"
],

@@ -28,3 +53,3 @@ "publishConfig": {

"devDependencies": {
"@wick-charts/core": "^0.2.2"
"@wick-charts/core": "^0.2.3"
},

@@ -31,0 +56,0 @@ "scripts": {