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}
)
}