Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 1 | import { useState } from 'react' |
| 2 | import type { FeedResult } from './Feeds' |
| 3 | import { INTERVALS, type Interval } from './plan' |
| 4 | import { DATABENTO_INTRADAY_TIP, SOURCES, SOURCE_INTERVALS, SOURCE_NAMES, type Source } from './sources' |
| 5 | |
| 6 | export const COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444', '#a855f7', '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#94a3b8'] |
| 7 | |
| 8 | export interface ToolbarProps { |
| 9 | symbols: string[] |
| 10 | active: string | null |
| 11 | source: Source |
| 12 | onSource: (s: Source) => void |
| 13 | interval: Interval |
| 14 | mode: 'lines' | 'candles' |
| 15 | volume: boolean |
| 16 | feeds: Record<string, FeedResult | undefined> |
| 17 | onInterval: (i: Interval) => void |
| 18 | onMode: (m: 'lines' | 'candles') => void |
| 19 | onVolume: (v: boolean) => void |
| 20 | onAdd: (symbol: string) => void |
| 21 | onRemove: (symbol: string) => void |
| 22 | onActive: (symbol: string) => void |
| 23 | } |
| 24 | |
| 25 | /** The controls row and the symbol chips. */ |
| 26 | export function Toolbar(p: ToolbarProps) { |
| 27 | const [draft, setDraft] = useState('') |
| 28 | const add = () => { |
| 29 | const symbol = draft.trim().toUpperCase() |
| 30 | if (/^[A-Z0-9.^=-]{1,20}$/.test(symbol)) p.onAdd(symbol) |
| 31 | setDraft('') |
| 32 | } |
| 33 | return ( |
| 34 | <div className="yc-top"> |
| 35 | <div className="yc-controls"> |
| 36 | <select className="yc-select" value={p.source} onChange={(e) => p.onSource(e.target.value as Source)} title="Data source"> |
| 37 | {SOURCES.map((s) => ( |
| 38 | <option key={s} value={s}> |
| 39 | {SOURCE_NAMES[s]} |
| 40 | </option> |
| 41 | ))} |
| 42 | </select> |
| 43 | <select |
| 44 | className="yc-select" |
| 45 | value={p.interval} |
| 46 | onChange={(e) => p.onInterval(e.target.value as Interval)} |
| 47 | title={p.source === 'databento' ? DATABENTO_INTRADAY_TIP : 'Interval'} |
| 48 | > |
| 49 | {INTERVALS.map((i) => { |
| 50 | const off = !SOURCE_INTERVALS[p.source].includes(i) |
| 51 | return ( |
| 52 | <option key={i} value={i} disabled={off} title={off ? DATABENTO_INTRADAY_TIP : undefined}> |
| 53 | {i} |
| 54 | </option> |
| 55 | ) |
| 56 | })} |
| 57 | </select> |
| 58 | <span className="yc-source">{SOURCE_NAMES[p.source]}</span> |
| 59 | <div className="yc-seg"> |
| 60 | {(['lines', 'candles'] as const).map((m) => ( |
| 61 | <button key={m} className={p.mode === m ? 'on' : ''} onClick={() => p.onMode(m)}> |
| 62 | {m === 'lines' ? 'Lines' : 'Candles'} |
| 63 | </button> |
| 64 | ))} |
| 65 | </div> |
| 66 | <button className={`yc-btn ${p.volume ? 'on' : ''}`} onClick={() => p.onVolume(!p.volume)} title="Volume pane"> |
| 67 | Vol |
| 68 | </button> |
| 69 | <input |
| 70 | className="yc-input" |
| 71 | value={draft} |
| 72 | placeholder="Add symbol" |
| 73 | onChange={(e) => setDraft(e.target.value)} |
| 74 | onKeyDown={(e) => { |
| 75 | if (e.key === 'Enter') add() |
| 76 | }} |
| 77 | /> |
| 78 | <button className="yc-btn" onClick={add} title="Add symbol"> |
| 79 | + |
| 80 | </button> |
| 81 | </div> |
| 82 | <div className="yc-chips"> |
| 83 | {p.symbols.map((s, i) => { |
| 84 | const feed = p.feeds[s] |
| 85 | const color = COLORS[i % COLORS.length] |
| 86 | return ( |
| 87 | <span |
| 88 | key={s} |
| 89 | className={`yc-chip ${s === p.active ? 'active' : ''} ${feed?.loading ? 'loading' : ''}`} |
| 90 | style={{ borderColor: s === p.active ? color : undefined }} |
| 91 | onClick={() => p.onActive(s)} |
| 92 | title={[s, feed?.note, feed?.error ?? feed?.detail].filter(Boolean).join(': ')} |
| 93 | > |
| 94 | <i style={{ background: color }} /> |
| 95 | {s} |
| 96 | {feed?.error ? <b className="yc-err">!</b> : null} |
| 97 | {feed?.note ? ( |
| 98 | <small className={`yc-note-chip ${feed.error || /fail/.test(feed.note) ? 'bad' : /rate/.test(feed.note) ? 'warn' : ''}`}> |
| 99 | {feed.note} |
| 100 | </small> |
| 101 | ) : null} |
| 102 | <button |
| 103 | className="yc-x" |
| 104 | onClick={(e) => { |
| 105 | e.stopPropagation() |
| 106 | p.onRemove(s) |
| 107 | }} |
| 108 | title={`Remove ${s}`} |
| 109 | > |
| 110 | × |
| 111 | </button> |
| 112 | </span> |
| 113 | ) |
| 114 | })} |
| 115 | </div> |
| 116 | </div> |
| 117 | ) |
| 118 | } |