Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 1 | import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 3 | import { PriceChart, type LegendEntry, type PriceCheck } from './chart' |
| 4 | import { SymbolFeed, type FeedResult } from './Feeds' |
| 5 | import { DEFAULT_SYMBOLS, isIntraday, type Interval } from './plan' |
| 6 | import { COLORS, Toolbar } from './Toolbar' |
| 7 | import { QueuedFeeds, type BarCache } from './Queue' |
| 8 | import { SOURCES, SOURCE_INTERVALS, effectiveInterval, type Source } from './sources' |
| 9 | import './styles.css' |
| 10 | |
| 11 | const REFRESH_MS = 5 * 60_000 |
| 12 | |
| 13 | export function ChartView({ panel }: { panel: PanelRef }) { |
| 14 | const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) |
| 15 | if (state === undefined) return <p className="yc-note">Loading…</p> |
| 16 | return <Chart panel={panel} /> |
| 17 | } |
| 18 | |
| 19 | function Chart({ panel }: { panel: PanelRef }) { |
| 20 | const [symbols, setSymbols] = usePanelState(panel, 'symbols', DEFAULT_SYMBOLS) as [string[], (v: string[]) => void] |
| 21 | const [stateInterval, setInterval_] = usePanelState(panel, 'interval', '1d') as [Interval, (v: Interval) => void] |
| 22 | const [mode, setMode] = usePanelState(panel, 'mode', 'lines') as ['lines' | 'candles', (v: 'lines' | 'candles') => void] |
| 23 | const [stateActive, setActive] = usePanelState(panel, 'active', null) as [string | null, (v: string | null) => void] |
| 24 | const [volume, setVolume] = usePanelState(panel, 'volume', true) as [boolean, (v: boolean) => void] |
| 25 | const [stateSource, setSource] = usePanelState(panel, 'source', 'yahoo') as [Source | undefined, (v: Source) => void] |
| 26 | const source: Source = stateSource && SOURCES.includes(stateSource) ? stateSource : 'yahoo' |
| 27 | // Databento offers 1d and 1wk only: an intraday interval left in state draws as 1d and is set so. |
| 28 | const interval = effectiveInterval(source, stateInterval ?? '1d') |
| 29 | useEffect(() => { |
| 30 | if (stateInterval && stateInterval !== interval) setInterval_(interval) |
| 31 | }, [stateInterval, interval]) |
| 32 | const cache = useRef<BarCache>(new Map()) |
| 33 | const list = symbols ?? DEFAULT_SYMBOLS |
| 34 | const active = stateActive && list.includes(stateActive) ? stateActive : (list[0] ?? null) |
| 35 | |
| 36 | // Feeds keyed by interval and symbol, so bars of two intervals never meet in one chart. |
| 37 | const [feeds, setFeeds] = useState<Record<string, FeedResult>>({}) |
| 38 | const onResult = useCallback( |
| 39 | (key: string, result: FeedResult) => |
| 40 | setFeeds((prev) => { |
| 41 | const old = prev[key] |
| 42 | const same = |
| 43 | old && |
| 44 | old.error === result.error && |
| 45 | old.loading === result.loading && |
| 46 | old.note === result.note && |
| 47 | old.detail === result.detail && |
| 48 | old.bars.length === result.bars.length && |
| 49 | (old.bars.length === 0 || |
| 50 | (old.bars[old.bars.length - 1].time === result.bars[result.bars.length - 1].time && |
| 51 | old.bars[old.bars.length - 1].close === result.bars[result.bars.length - 1].close && |
| 52 | old.bars[old.bars.length - 1].volume === result.bars[result.bars.length - 1].volume)) |
| 53 | return same ? prev : { ...prev, [key]: result } |
| 54 | }), |
| 55 | [], |
| 56 | ) |
| 57 | const [tick, setTick] = useState(0) |
| 58 | useEffect(() => { |
| 59 | const timer = setInterval(() => setTick((n) => n + 1), REFRESH_MS) |
| 60 | return () => clearInterval(timer) |
| 61 | }, []) |
| 62 | const current = useMemo(() => { |
| 63 | const out: Record<string, FeedResult | undefined> = {} |
| 64 | for (const s of list) out[s] = feeds[`${source}:${interval}:${s}`] |
| 65 | return out |
| 66 | }, [feeds, list, interval, source]) |
| 67 | |
| 68 | const holder = useRef<HTMLDivElement>(null) |
| 69 | const chart = useRef<PriceChart | null>(null) |
| 70 | const [legend, setLegend] = useState<LegendEntry[]>([]) |
| 71 | const [check, setCheck] = useState<PriceCheck | null>(null) |
| 72 | useEffect(() => { |
| 73 | if (!holder.current) return |
| 74 | chart.current = new PriceChart(holder.current, (entries, c) => { |
| 75 | setLegend(entries) |
| 76 | setCheck((prev) => |
| 77 | prev && prev.series === c.series && prev.points === c.points && prev.finite === c.finite && prev.pane === c.pane && prev.volumePane === c.volumePane |
| 78 | ? prev |
| 79 | : c, |
| 80 | ) |
| 81 | }) |
| 82 | return () => { |
| 83 | chart.current?.destroy() |
| 84 | chart.current = null |
| 85 | } |
| 86 | }, []) |
| 87 | |
| 88 | const loading = list.some((s) => !current[s] || current[s]?.loading) |
| 89 | useEffect(() => { |
| 90 | chart.current?.update({ |
| 91 | mode, |
| 92 | active, |
| 93 | volume: volume !== false, |
| 94 | intraday: isIntraday(interval), |
| 95 | volumeLabel: source === 'databento' ? 'Volume (DBEQ venues)' : null, |
| 96 | dataKey: `${source}:${interval}:${loading ? 'loading' : 'done'}`, |
| 97 | series: list.map((s, i) => ({ symbol: s, color: COLORS[i % COLORS.length], bars: current[s]?.bars ?? [] })), |
| 98 | }) |
| 99 | }, [current, list, mode, active, volume, interval, loading, source]) |
| 100 | |
| 101 | const failed = list.filter((s) => current[s]?.error) |
| 102 | usePublish(panel, { |
| 103 | symbols: list, |
| 104 | source, |
| 105 | interval, |
| 106 | mode, |
| 107 | active, |
| 108 | loaded: list.filter((s) => (current[s]?.bars.length ?? 0) > 0), |
| 109 | failed, |
| 110 | price: check, |
| 111 | }) |
| 112 | |
| 113 | const remove = (symbol: string) => { |
| 114 | const next = list.filter((s) => s !== symbol) |
| 115 | if (symbol === active) { |
| 116 | const i = list.indexOf(symbol) |
| 117 | setActive(next[i] ?? next[next.length - 1] ?? null) |
| 118 | } |
| 119 | setSymbols(next) |
| 120 | } |
| 121 | |
| 122 | return ( |
| 123 | <div className="yc"> |
| 124 | {source === 'yahoo' ? ( |
| 125 | list.map((s) => <SymbolFeed key={`${interval}:${s}`} symbol={s} interval={interval} tick={tick} onResult={onResult} />) |
| 126 | ) : ( |
| 127 | <QueuedFeeds |
| 128 | key={`${source}:${interval}`} |
| 129 | source={source} |
| 130 | symbols={list} |
| 131 | interval={interval} |
| 132 | tick={tick} |
| 133 | cache={cache.current} |
| 134 | onResult={onResult} |
| 135 | /> |
| 136 | )} |
| 137 | <Toolbar |
| 138 | source={source} |
| 139 | onSource={(s) => { |
| 140 | setSource(s) |
| 141 | if (!SOURCE_INTERVALS[s].includes(interval)) setInterval_('1d') |
| 142 | }} |
| 143 | symbols={list} |
| 144 | active={active} |
| 145 | interval={interval} |
| 146 | mode={mode} |
| 147 | volume={volume !== false} |
| 148 | feeds={current} |
| 149 | onInterval={setInterval_} |
| 150 | onMode={setMode} |
| 151 | onVolume={setVolume} |
| 152 | onAdd={(s) => { |
| 153 | if (!list.includes(s)) setSymbols([...list, s]) |
| 154 | }} |
| 155 | onRemove={remove} |
| 156 | onActive={setActive} |
| 157 | /> |
| 158 | <div className="yc-body"> |
| 159 | <div className="yc-chart" ref={holder} /> |
| 160 | <div className="yc-legend"> |
| 161 | {legend.map((e) => ( |
| 162 | <span key={e.symbol} style={{ color: e.color }}> |
| 163 | {e.symbol} <em>{e.text}</em> |
| 164 | </span> |
| 165 | ))} |
| 166 | </div> |
| 167 | {list.length === 0 ? <p className="yc-note yc-empty">Add a symbol above.</p> : null} |
| 168 | </div> |
| 169 | </div> |
| 170 | ) |
| 171 | } |