@wick-charts/react
Advanced tools
| 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; | ||
| } |
+120
| // 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> | ||
| </> | ||
| ); | ||
| } |
+122
| 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": { |
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
567879
21.66%27
350%11200
32.89%3
-25%1
-50%