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.

13 KB
1// The lightweight-charts chart: series per mode, the volume pane, rebasing lines on the visible range,
2// and New York time labels.
3import {
4 CandlestickSeries,
5 ColorType,
6 CrosshairMode,
7 HistogramSeries,
8 LineSeries,
9 createChart,
10 type IChartApi,
11 type ISeriesApi,
12 type MouseEventParams,
13 type SeriesType,
14 type Time,
15} from 'lightweight-charts'
16import type { Bar } from './plan'
17import { crosshairLabel, tickLabel } from './time'
18
19export interface SeriesInput {
20 symbol: string
21 color: string
22 bars: Bar[]
23}
24
25export interface ChartModel {
26 mode: 'lines' | 'candles'
27 series: SeriesInput[]
28 active: string | null
29 volume: boolean
30 intraday: boolean
31 /** A caption at the top of the volume pane, or null for none. */
32 volumeLabel?: string | null
33 /** Changes when the interval changes: the chart fits all data again. */
34 dataKey: string
35}
36
37export interface LegendEntry {
38 symbol: string
39 color: string
40 text: string
41}
42
43/** What the price pane holds after an update, as the view reports it. pane -1 means its series are split across panes. */
44export interface PriceCheck {
45 series: number
46 points: number
47 finite: boolean
48 pane: number | null
49 volumePane: number | null
50}
51
52const PRICE_PANE = 0
53const VOLUME_PANE = 1
54const UP = '#26a69a'
55const DOWN = '#ef5350'
56const pct = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`
57const price = (v: number) => (Math.abs(v) >= 1000 ? v.toFixed(0) : Math.abs(v) >= 1 ? v.toFixed(2) : v.toFixed(4))
58const vol = (v: number) =>
59 v >= 1e9 ? `${(v / 1e9).toFixed(2)}B` : v >= 1e6 ? `${(v / 1e6).toFixed(2)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}K` : String(v)
60const t = (bar: Bar) => bar.time as Time
61/** Whether time a comes before b: seconds against seconds, YYYY-MM-DD against YYYY-MM-DD. */
62const before = (a: number | string, b: number | string | { year: number; month: number; day: number }) => {
63 if (typeof b === 'object') b = `${b.year}-${String(b.month).padStart(2, '0')}-${String(b.day).padStart(2, '0')}`
64 return typeof a === 'number' && typeof b === 'number' ? a < b : String(a) < String(b)
65}
66
67export class PriceChart {
68 private chart: IChartApi
69 private model: ChartModel | null = null
70 private lines = new Map<string, ISeriesApi<'Line'>>()
71 private candles: ISeriesApi<'Candlestick'> | null = null
72 private volumeSeries: ISeriesApi<'Histogram'> | null = null
73 private hover: MouseEventParams<Time> | null = null
74 private frame = 0
75 private onDblClick = () => this.fit()
76
77 private onLegend: (entries: LegendEntry[]) => void
78
79 constructor(private el: HTMLElement, onLegend: (entries: LegendEntry[], check: PriceCheck) => void) {
80 this.onLegend = (entries) => onLegend(entries, this.check())
81 this.chart = createChart(el, {
82 autoSize: true,
83 layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#a3a3a3', fontSize: 11, attributionLogo: true },
84 grid: { vertLines: { color: 'rgba(255,255,255,0.05)' }, horzLines: { color: 'rgba(255,255,255,0.05)' } },
85 rightPriceScale: { borderColor: 'rgba(255,255,255,0.12)' },
86 timeScale: {
87 borderColor: 'rgba(255,255,255,0.12)',
88 timeVisible: true,
89 secondsVisible: false,
90 rightOffset: 3,
91 tickMarkFormatter: (time: Time, markType: number) => tickLabel(time, markType),
92 },
93 crosshair: { mode: CrosshairMode.Normal },
94 localization: { timeFormatter: (time: Time) => crosshairLabel(time) },
95 handleScroll: true,
96 handleScale: true,
97 })
98 // Pane 0 is the price pane for the chart's whole life. lightweight-charts v5 deletes a pane when its
99 // last series is removed, which happens whenever the price series are swapped (an interval change in
100 // lines mode removes every line until the new bars arrive; a mode switch removes the old series
101 // first). The volume pane then slid up to index 0 and the next price series landed beside the
102 // volume, on its scale, flattened to nothing.
103 ;(this.chart.panes()[0] as unknown as { setPreserveEmptyPane?: (p: boolean) => void } | undefined)?.setPreserveEmptyPane?.(true)
104 this.chart.timeScale().subscribeVisibleTimeRangeChange(() => this.schedule())
105 this.chart.subscribeCrosshairMove((param) => {
106 this.hover = param.time === undefined ? null : param
107 this.legend()
108 })
109 el.addEventListener('dblclick', this.onDblClick)
110 this.label.className = 'yc-vol-label'
111 this.label.style.display = 'none'
112 el.appendChild(this.label)
113 this.resize = new ResizeObserver(() => requestAnimationFrame(() => this.placeLabel()))
114 this.resize.observe(el)
115 }
116
117 private label = document.createElement('div')
118 private resize: ResizeObserver
119
120 /** The volume caption, at the top of the volume pane. */
121 private placeLabel(): void {
122 const text = this.model?.volume && this.volumeSeries ? this.model.volumeLabel : null
123 if (!text) {
124 this.label.style.display = 'none'
125 return
126 }
127 const pane = this.chart.panes()[0] as unknown as { getHeight?: () => number } | undefined
128 this.label.textContent = text
129 this.label.style.top = `${(pane?.getHeight?.() ?? 0) + 4}px`
130 this.label.style.display = 'block'
131 }
132
133 update(model: ChartModel): void {
134 const before = this.model
135 const refit = !before || before.dataKey !== model.dataKey || before.mode !== model.mode
136 const hadData = !!before && before.series.some((s) => s.bars.length > 0)
137 const range = refit ? null : this.chart.timeScale().getVisibleRange()
138 this.model = model
139 this.chart.applyOptions({ timeScale: { timeVisible: model.intraday } })
140
141 if (model.mode === 'lines') {
142 this.remove(this.candles)
143 this.candles = null
144 const wanted = new Set(model.series.filter((s) => s.bars.length > 0).map((s) => s.symbol))
145 for (const [symbol, series] of this.lines)
146 if (!wanted.has(symbol)) {
147 this.chart.removeSeries(series)
148 this.lines.delete(symbol)
149 }
150 for (const s of model.series) {
151 if (!wanted.has(s.symbol)) continue
152 const opts = { color: s.color, lineWidth: s.symbol === model.active ? 2 : 1, priceLineVisible: false, lastValueVisible: true }
153 const line = this.lines.get(s.symbol)
154 if (line) line.applyOptions(opts)
155 else {
156 this.bases.delete(s.symbol)
157 this.lines.set(
158 s.symbol,
159 this.chart.addSeries(LineSeries, {
160 ...opts,
161 title: '',
162 priceFormat: { type: 'custom', formatter: pct, minMove: 0.01 },
163 }, PRICE_PANE),
164 )
165 }
166 }
167 } else {
168 for (const series of this.lines.values()) this.chart.removeSeries(series)
169 this.lines.clear()
170 this.bases.clear()
171 const bars = model.series.find((s) => s.symbol === model.active)?.bars ?? []
172 if (!this.candles)
173 this.candles = this.chart.addSeries(CandlestickSeries, {
174 upColor: UP,
175 downColor: DOWN,
176 borderVisible: false,
177 wickUpColor: UP,
178 wickDownColor: DOWN,
179 priceFormat: { type: 'custom', formatter: price, minMove: 0.0001 },
180 }, PRICE_PANE)
181 this.candles.setData(bars.map((b) => ({ time: t(b), open: b.open, high: b.high, low: b.low, close: b.close })))
182 }
183
184 this.updateVolume(model)
185 this.checkPanes(model)
186 this.rebase()
187
188 if (refit || !hadData) this.fit()
189 else if (range) {
190 try {
191 this.chart.timeScale().setVisibleRange(range)
192 } catch {
193 // The range is gone with the data: leave the chart where it is.
194 }
195 }
196 this.legend()
197 this.placeLabel()
198 requestAnimationFrame(() => this.placeLabel())
199 }
200
201 /** The active symbol's volume in a pane of its own at the bottom, colored by the bar's direction. */
202 private updateVolume(model: ChartModel): void {
203 const bars = model.series.find((s) => s.symbol === model.active)?.bars ?? []
204 if (!model.volume) {
205 this.remove(this.volumeSeries)
206 this.volumeSeries = null
207 if (this.chart.panes().length > 1)
208 try {
209 this.chart.removePane(1)
210 } catch {
211 // Already gone with its last series.
212 }
213 return
214 }
215 if (!this.volumeSeries) {
216 this.volumeSeries = this.chart.addSeries(
217 HistogramSeries,
218 { priceFormat: { type: 'custom', formatter: vol, minMove: 1 }, priceLineVisible: false, lastValueVisible: false },
219 VOLUME_PANE,
220 )
221 const panes = this.chart.panes() as unknown as { setStretchFactor?: (n: number) => void; setHeight?: (n: number) => void }[]
222 if (panes[0]?.setStretchFactor && panes[1]?.setStretchFactor) {
223 panes[0].setStretchFactor(4)
224 panes[1].setStretchFactor(1)
225 } else panes[1]?.setHeight?.(Math.max(40, Math.round(this.el.clientHeight * 0.2)))
226 }
227 this.volumeSeries.setData(
228 bars.map((b) => ({ time: t(b), value: b.volume, color: b.close >= b.open ? 'rgba(38,166,154,0.55)' : 'rgba(239,83,80,0.55)' })),
229 )
230 }
231
232 /**
233 * The price series in pane 0 and the volume alone in pane 1. Should the price pane have been dropped
234 * all the same (a lightweight-charts without setPreserveEmptyPane), the volume series is put back in
235 * a pane of its own below the price.
236 */
237 private checkPanes(model: ChartModel): void {
238 const v = this.volumeSeries
239 if (!v) return
240 const pane = v.getPane()
241 if (pane.paneIndex() === VOLUME_PANE && pane.getSeries().length === 1) return
242 this.chart.removeSeries(v)
243 this.volumeSeries = null
244 this.updateVolume(model)
245 }
246
247 /** What the price pane holds, for the view to report: points, whether all are finite, and its pane. */
248 private check(): PriceCheck {
249 const series: ISeriesApi<SeriesType>[] = this.model?.mode === 'candles' ? (this.candles ? [this.candles] : []) : [...this.lines.values()]
250 let points = 0
251 let finite = true
252 let pane: number | null = null
253 for (const s of series) {
254 const data = s.data() as { value?: number; open?: number; high?: number; low?: number; close?: number }[]
255 points += data.length
256 for (const d of data) {
257 const values = 'value' in d ? [d.value] : [d.open, d.high, d.low, d.close]
258 if (!values.every((x) => typeof x === 'number' && Number.isFinite(x))) finite = false
259 }
260 const index = s.getPane().paneIndex()
261 pane = pane === null || pane === index ? index : -1
262 }
263 const volumePane = this.volumeSeries ? this.volumeSeries.getPane().paneIndex() : null
264 return { series: series.length, points, finite: points > 0 && finite, pane, volumePane }
265 }
266
267 /** Every bar on screen. */
268 fit(): void {
269 this.chart.timeScale().fitContent()
270 }
271
272 destroy(): void {
273 cancelAnimationFrame(this.frame)
274 this.resize.disconnect()
275 this.label.remove()
276 this.el.removeEventListener('dblclick', this.onDblClick)
277 this.chart.remove()
278 }
279
280 /** Rebases the lines once a frame while the visible range moves. */
281 private schedule(): void {
282 if (this.frame) return
283 this.frame = requestAnimationFrame(() => {
284 this.frame = 0
285 this.rebase()
286 this.placeLabel()
287 })
288 }
289
290 /** Each line as % change from its first bar on screen. */
291 private rebase(): void {
292 const model = this.model
293 if (!model || model.mode !== 'lines') return
294 const from = this.chart.timeScale().getVisibleRange()?.from as number | string | undefined
295 for (const s of model.series) {
296 const line = this.lines.get(s.symbol)
297 if (!line || s.bars.length === 0) continue
298 const first = from === undefined ? s.bars[0] : (s.bars.find((b) => !before(b.time, from)) ?? s.bars[s.bars.length - 1])
299 const base = first.close
300 if (!base) continue
301 const key = `${String(first.time)}:${base}:${s.bars.length}:${String(s.bars[s.bars.length - 1].time)}:${s.bars[s.bars.length - 1].close}`
302 if (this.bases.get(s.symbol) === key) continue
303 this.bases.set(s.symbol, key)
304 line.setData(s.bars.map((b) => ({ time: t(b), value: (b.close / base - 1) * 100 })))
305 }
306 this.legend()
307 }
308
309 private bases = new Map<string, string>()
310
311 /** The legend's lines: each symbol's hovered or latest value. */
312 private legend(): void {
313 const model = this.model
314 if (!model) return this.onLegend([])
315 const hovered = this.hover
316 if (model.mode === 'lines') {
317 this.onLegend(
318 model.series.map((s) => {
319 const line = this.lines.get(s.symbol)
320 if (!line) return { symbol: s.symbol, color: s.color, text: '–' }
321 const at = hovered?.seriesData.get(line) as { value?: number } | undefined
322 const data = line.data()
323 const last = data[data.length - 1] as { value?: number } | undefined
324 const value = at?.value ?? last?.value
325 return { symbol: s.symbol, color: s.color, text: value === undefined ? '–' : pct(value) }
326 }),
327 )
328 return
329 }
330 const s = model.series.find((x) => x.symbol === model.active)
331 if (!s || !this.candles || s.bars.length === 0) return this.onLegend(s ? [{ symbol: s.symbol, color: s.color, text: '–' }] : [])
332 const at = hovered?.seriesData.get(this.candles) as { time?: Time } | undefined
333 const index = at?.time === undefined ? s.bars.length - 1 : s.bars.findIndex((b) => String(b.time) === String(at.time))
334 const bar = s.bars[index < 0 ? s.bars.length - 1 : index]
335 const prev = s.bars[(index < 0 ? s.bars.length - 1 : index) - 1]
336 const change = prev ? ` ${pct((bar.close / prev.close - 1) * 100)}` : ''
337 this.onLegend([
338 {
339 symbol: s.symbol,
340 color: s.color,
341 text: `O ${price(bar.open)} H ${price(bar.high)} L ${price(bar.low)} C ${price(bar.close)}${change} V ${vol(bar.volume)}`,
342 },
343 ])
344 }
345
346 private remove(series: ISeriesApi<SeriesType> | null): void {
347 if (series) this.chart.removeSeries(series)
348 }
349}
350
351export { UP, DOWN, pct, price, vol, t }