Jaspers Hub

biseroff / charts

pluginReviewed★ 0↓ 0

Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.

6.5 KB
1import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
2import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3import { PriceChart, type LegendEntry, type PriceCheck } from './chart'
4import { SymbolFeed, type FeedResult } from './Feeds'
5import { DEFAULT_SYMBOLS, isIntraday, type Interval } from './plan'
6import { COLORS, Toolbar } from './Toolbar'
7import { QueuedFeeds, type BarCache } from './Queue'
8import { SOURCES, SOURCE_INTERVALS, effectiveInterval, type Source } from './sources'
9import './styles.css'
10
11const REFRESH_MS = 5 * 60_000
12
13export 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
19function 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}