import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PriceChart, type LegendEntry, type PriceCheck } from './chart' import { SymbolFeed, type FeedResult } from './Feeds' import { DEFAULT_SYMBOLS, isIntraday, type Interval } from './plan' import { COLORS, Toolbar } from './Toolbar' import { QueuedFeeds, type BarCache } from './Queue' import { SOURCES, SOURCE_INTERVALS, effectiveInterval, type Source } from './sources' import './styles.css' const REFRESH_MS = 5 * 60_000 export function ChartView({ panel }: { panel: PanelRef }) { const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) if (state === undefined) return

Loading…

return } function Chart({ panel }: { panel: PanelRef }) { const [symbols, setSymbols] = usePanelState(panel, 'symbols', DEFAULT_SYMBOLS) as [string[], (v: string[]) => void] const [stateInterval, setInterval_] = usePanelState(panel, 'interval', '1d') as [Interval, (v: Interval) => void] const [mode, setMode] = usePanelState(panel, 'mode', 'lines') as ['lines' | 'candles', (v: 'lines' | 'candles') => void] const [stateActive, setActive] = usePanelState(panel, 'active', null) as [string | null, (v: string | null) => void] const [volume, setVolume] = usePanelState(panel, 'volume', true) as [boolean, (v: boolean) => void] const [stateSource, setSource] = usePanelState(panel, 'source', 'yahoo') as [Source | undefined, (v: Source) => void] const source: Source = stateSource && SOURCES.includes(stateSource) ? stateSource : 'yahoo' // Databento offers 1d and 1wk only: an intraday interval left in state draws as 1d and is set so. const interval = effectiveInterval(source, stateInterval ?? '1d') useEffect(() => { if (stateInterval && stateInterval !== interval) setInterval_(interval) }, [stateInterval, interval]) const cache = useRef(new Map()) const list = symbols ?? DEFAULT_SYMBOLS const active = stateActive && list.includes(stateActive) ? stateActive : (list[0] ?? null) // Feeds keyed by interval and symbol, so bars of two intervals never meet in one chart. const [feeds, setFeeds] = useState>({}) const onResult = useCallback( (key: string, result: FeedResult) => setFeeds((prev) => { const old = prev[key] const same = old && old.error === result.error && old.loading === result.loading && old.note === result.note && old.detail === result.detail && old.bars.length === result.bars.length && (old.bars.length === 0 || (old.bars[old.bars.length - 1].time === result.bars[result.bars.length - 1].time && old.bars[old.bars.length - 1].close === result.bars[result.bars.length - 1].close && old.bars[old.bars.length - 1].volume === result.bars[result.bars.length - 1].volume)) return same ? prev : { ...prev, [key]: result } }), [], ) const [tick, setTick] = useState(0) useEffect(() => { const timer = setInterval(() => setTick((n) => n + 1), REFRESH_MS) return () => clearInterval(timer) }, []) const current = useMemo(() => { const out: Record = {} for (const s of list) out[s] = feeds[`${source}:${interval}:${s}`] return out }, [feeds, list, interval, source]) const holder = useRef(null) const chart = useRef(null) const [legend, setLegend] = useState([]) const [check, setCheck] = useState(null) useEffect(() => { if (!holder.current) return chart.current = new PriceChart(holder.current, (entries, c) => { setLegend(entries) setCheck((prev) => prev && prev.series === c.series && prev.points === c.points && prev.finite === c.finite && prev.pane === c.pane && prev.volumePane === c.volumePane ? prev : c, ) }) return () => { chart.current?.destroy() chart.current = null } }, []) const loading = list.some((s) => !current[s] || current[s]?.loading) useEffect(() => { chart.current?.update({ mode, active, volume: volume !== false, intraday: isIntraday(interval), volumeLabel: source === 'databento' ? 'Volume (DBEQ venues)' : null, dataKey: `${source}:${interval}:${loading ? 'loading' : 'done'}`, series: list.map((s, i) => ({ symbol: s, color: COLORS[i % COLORS.length], bars: current[s]?.bars ?? [] })), }) }, [current, list, mode, active, volume, interval, loading, source]) const failed = list.filter((s) => current[s]?.error) usePublish(panel, { symbols: list, source, interval, mode, active, loaded: list.filter((s) => (current[s]?.bars.length ?? 0) > 0), failed, price: check, }) const remove = (symbol: string) => { const next = list.filter((s) => s !== symbol) if (symbol === active) { const i = list.indexOf(symbol) setActive(next[i] ?? next[next.length - 1] ?? null) } setSymbols(next) } return (
{source === 'yahoo' ? ( list.map((s) => ) ) : ( )} { setSource(s) if (!SOURCE_INTERVALS[s].includes(interval)) setInterval_('1d') }} symbols={list} active={active} interval={interval} mode={mode} volume={volume !== false} feeds={current} onInterval={setInterval_} onMode={setMode} onVolume={setVolume} onAdd={(s) => { if (!list.includes(s)) setSymbols([...list, s]) }} onRemove={remove} onActive={setActive} />
{legend.map((e) => ( {e.symbol} {e.text} ))}
{list.length === 0 ?

Add a symbol above.

: null}
) }