SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

web: page /charts (barre d'outils, recherche de symbole, moteur, légende, barre d'état, dessins, table, mobile) + route, nav, prérendu, SPA_ROUTES

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 1ea2d30

19 changed files +1,355 −9

modified hfmarketdata/api/core/spa.py +1 −1
@@ -25,7 +25,7 @@ from .errors import ApiError
25 25 # Paths owned by the API — never answered by the SPA shell.
26 26 API_PREFIXES = ("v1/", "health", "openapi.json", "swagger", "redoc", "assets/")
27 27 # Routes known to the React router (web/src/App.jsx). Anything else gets the shell with a 404 status.
28 −SPA_ROUTES = frozenset({"", "docs", "playground", "integrations", "limits", "pricing", "status", "signin", "signup",
28 +SPA_ROUTES = frozenset({"", "docs", "playground", "charts", "integrations", "limits", "pricing", "status", "signin", "signup",
29 29 "verify", "reset", "invite", "accept-invite", "reset-password", "dashboard", "admin",
30 30 "changelog", "terms", "privacy", "data-license"})
31 31 SPA_PREFIXES = ("docs/", "integrations/", "dashboard/", "admin/")
modified hfmarketdata/web/scripts/prerender.mjs +4 −1
@@ -16,7 +16,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
16 16 const DIST = join(ROOT, 'dist')
17 17 const SITE = 'https://www.hfmarketdata.io'
18 18
19 −const NAV = [['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'], ['/limits', 'Limits'], ['/status', 'Status']]
19 +const NAV = [['/docs', 'Docs'], ['/playground', 'Playground'], ['/charts', 'Charts'], ['/integrations', 'Integrations'], ['/limits', 'Limits'], ['/status', 'Status']]
20 20
21 21 const HOME_LEAD = 'Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, docs generated from the spec, a playground, and limits that only grow.'
22 22
@@ -33,6 +33,9 @@ const STATIC_ROUTES = [
33 33 { path: '/playground', entry: 'src/pages/playground/Playground.jsx', title: 'Playground · HF Market Data',
34 34 description: 'Run any HF Market Data request in the browser: pick an endpoint, edit parameters, inspect JSON, table or chart, copy the code.', h1: 'Playground',
35 35 lead: 'Pick an endpoint, edit the parameters, run it against the live API and inspect the result as JSON, table or chart — then copy the code.' },
36 + { path: '/charts', entry: 'src/pages/charts/ChartsPage.jsx', title: 'Charts · HF Market Data',
37 + description: 'Interactive charts on free market data: stocks, ETFs, futures, crypto, indices and FX from 1-minute to daily — candles, indicators, comparisons, drawings, shareable URLs.', h1: 'Charts',
38 + lead: 'Candles, indicators, comparisons and drawings on every symbol of the API, from 1-minute to daily. Infinite history, shareable URL, keyboard first.' },
36 39 { path: '/integrations', entry: 'src/pages/integrations/Integrations.jsx', title: 'Integrations — MCP server, Claude Code, Cursor, Codex, skills · HF Market Data',
37 40 description: 'Use HF Market Data from Claude Code, Cursor, Codex and any MCP client: one MCP server with 14 read-only tools, plus a skills pack with ready-to-run Python.', h1: 'Market data where your agent already works.',
38 41 lead: 'One MCP server gives Claude Code, Cursor, Codex and Claude Desktop 14 read-only tools over the whole HF Market Data API. Plus five Claude skills with ready-to-run Python.' },
modified hfmarketdata/web/src/App.jsx +3 −1
@@ -1,6 +1,6 @@
1 1 // Route table — one lazy chunk per area. Owners: web-core (home, limits, docs, status, legal, layout, 404),
2 2 // web-app (playground, auth, dashboard, admin), mcp-skills (integrations).
3 −// Public routes (mirrored by the server's SPA whitelist): / /docs/* /playground /integrations/* /limits /pricing
3 +// Public routes (mirrored by the server's SPA whitelist): / /docs/* /playground /charts /integrations/* /limits /pricing
4 4 // /status /terms /privacy /data-license /changelog (→ /docs/changelog) + auth/dashboard/admin.
5 5 import React, { Suspense, lazy } from 'react'
6 6 import { Navigate, Route, Routes } from 'react-router-dom'
@@ -17,6 +17,7 @@ const NotFound = lazy(() => import('./pages/NotFound.jsx'))
17 17 const Auth = lazy(() => import('./pages/auth/Auth.jsx'))
18 18 const Dashboard = lazy(() => import('./pages/dashboard/Dashboard.jsx'))
19 19 const Admin = lazy(() => import('./pages/admin/Admin.jsx'))
20 +const Charts = lazy(() => import('./pages/charts/ChartsPage.jsx')) // chartpage agent — canvas engine in src/charts/engine
20 21
21 22 export default function App() {
22 23 return (
@@ -26,6 +27,7 @@ export default function App() {
26 27 <Route path="/" element={<Home />} />
27 28 <Route path="/docs/*" element={<Docs />} />
28 29 <Route path="/playground" element={<Playground />} />
30 + <Route path="/charts" element={<Charts />} />
29 31 <Route path="/integrations/*" element={<Integrations />} />
30 32 <Route path="/limits" element={<Limits />} />
31 33 <Route path="/pricing" element={<Navigate to="/limits" replace />} />
modified hfmarketdata/web/src/app/Layout.jsx +6 −4
@@ -24,7 +24,7 @@ function useSearchShortcut(open) {
24 24 }
25 25
26 26 export const NAV = [
27 − ['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'],
27 + ['/docs', 'Docs'], ['/playground', 'Playground'], ['/charts', 'Charts'], ['/integrations', 'Integrations'],
28 28 ['/limits', 'Limits'], ['/status', 'Status'],
29 29 ]
30 30
@@ -63,6 +63,8 @@ export default function Layout({ children }) {
63 63 const session = user
64 64 ? <Link to="/dashboard" className="btn btn-ghost">Dashboard</Link>
65 65 : <Link to="/signin" className="btn btn-ghost btn-signin">Sign in</Link>
66 + // /charts is a full-height workspace (chart fills the viewport under the header): no footer there.
67 + const workspace = location.pathname === '/charts'
66 68
67 69 return (
68 70 <div className="shell">
@@ -108,7 +110,7 @@ export default function Layout({ children }) {
108 110
109 111 <div id="main" style={{ display: 'contents' }}>{children}</div>
110 112
111 − <footer className="footer">
113 + {!workspace && <footer className="footer">
112 114 <div className="footer-inner">
113 115 <div>
114 116 <div className="brand" style={{ marginBottom: 10 }}><span className="brand-mark" aria-hidden="true">HF</span>Market Data</div>
@@ -117,7 +119,7 @@ export default function Layout({ children }) {
117 119 </div>
118 120 <div>
119 121 <h4>Product</h4>
120 − <ul><li><Link to="/docs">Docs</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/limits">Limits</Link></li></ul>
122 + <ul><li><Link to="/docs">Docs</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/charts">Charts</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/limits">Limits</Link></li></ul>
121 123 </div>
122 124 <div>
123 125 <h4>Resources</h4>
@@ -136,7 +138,7 @@ export default function Layout({ children }) {
136 138 <span>© {new Date().getFullYear()} HF Market Data · Built by {AUTHOR}</span>
137 139 <span>Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</span>
138 140 </div>
139 − </footer>
141 + </footer>}
140 142
141 143 {searchOpen && (
142 144 <Suspense fallback={null}>
modified hfmarketdata/web/src/charts/data/state.js +4 −1
@@ -80,7 +80,10 @@ export function initialState(search) {
80 80 const last = readLast()
81 81 if (last?.ticker) Object.assign(base, { asset: last.asset || 'stock', ticker: last.ticker, label: last.label || last.ticker, tf: TIMEFRAMES.includes(last.tf) ? last.tf : '1day', type: last.type || 'candles' })
82 82 }
83 − return { ...base, ...fromUrl, indicators: (fromUrl.indicators || []).map(i => ({ ...i, colorIndex: undefined })) }
83 + const stamp = Date.now().toString(36)
84 + const indicators = (fromUrl.indicators || []).map((i, k) => ({ ...i, id: `ind-u${k}-${stamp}`, colorIndex: k }))
85 + const compares = (fromUrl.compares || []).map((c, k) => ({ ...c, id: `cmp-u${k}-${stamp}`, asset: c.asset || 'stock', colorIndex: indicators.length + k, loading: true }))
86 + return { ...base, ...fromUrl, indicators, compares }
84 87 }
85 88
86 89 export { INDICATORS }
added hfmarketdata/web/src/pages/charts/BarsTable.jsx +57 −0
@@ -0,0 +1,57 @@
1 +// Accessible table of the visible bars (newest first) + CSV export — every value reachable without hovering.
2 +import React, { useMemo } from 'react'
3 +import Table from '../../components/Table.jsx'
4 +import Button from '../../components/Button.jsx'
5 +import { CloseIcon } from '../../components/Icons.jsx'
6 +import { formatPrice, formatStampLabel, formatVolume } from '../../charts/data/session.js'
7 +import { TF_LABEL } from '../../charts/data/bars.js'
8 +
9 +function toCsv(rows, tf, tz) {
10 + const head = ['datetime', 'open', 'high', 'low', 'close', 'volume', 'change_pct']
11 + const lines = rows.map(r => [formatStampLabel(r.t, tf, ''), r.o, r.h, r.l, r.c, r.v ?? '', r.pct == null ? '' : r.pct.toFixed(4)].join(','))
12 + return `# HF Market Data — ${tz === 'ET' ? 'timestamps US/Eastern wall-clock' : 'timestamps UTC'}\n${head.join(',')}\n${lines.join('\n')}\n`
13 +}
14 +
15 +export default function BarsTable({ bars, range, state, decimals, tz, onClose, max = 400 }) {
16 + const rows = useMemo(() => {
17 + if (!bars?.length) return []
18 + const a = Math.max(0, range?.fromIndex ?? 0), b = Math.min(bars.length - 1, range?.toIndex ?? bars.length - 1)
19 + const out = []
20 + for (let i = b; i >= a && out.length < max; i--) {
21 + const bar = bars[i], prev = bars[i - 1]
22 + out.push({ ...bar, pct: prev ? ((bar.c - prev.c) / prev.c) * 100 : null })
23 + }
24 + return out
25 + }, [bars, range, max])
26 + const download = () => {
27 + const blob = new Blob([toCsv(rows, state.tf, tz)], { type: 'text/csv;charset=utf-8' })
28 + const a = document.createElement('a')
29 + a.href = URL.createObjectURL(blob)
30 + a.download = `${state.ticker}-${TF_LABEL[state.tf]}-visible.csv`
31 + a.click()
32 + setTimeout(() => URL.revokeObjectURL(a.href), 1000)
33 + }
34 + const num = v => formatPrice(v, decimals)
35 + const columns = [
36 + { key: 't', label: `Time (${tz})`, mono: true, render: r => formatStampLabel(r.t, state.tf, '') },
37 + { key: 'o', label: 'Open', align: 'right', mono: true, render: r => num(r.o) },
38 + { key: 'h', label: 'High', align: 'right', mono: true, render: r => num(r.h) },
39 + { key: 'l', label: 'Low', align: 'right', mono: true, render: r => num(r.l) },
40 + { key: 'c', label: 'Close', align: 'right', mono: true, render: r => num(r.c) },
41 + { key: 'pct', label: 'Δ %', align: 'right', mono: true, render: r => (r.pct == null ? '—' : <span className={r.pct >= 0 ? 'up' : 'down'}>{r.pct >= 0 ? '+' : ''}{r.pct.toFixed(2)}%</span>) },
42 + { key: 'v', label: 'Volume', align: 'right', mono: true, render: r => formatVolume(r.v) },
43 + ]
44 + return (
45 + <aside className="ch-table" aria-label="Visible bars" data-testid="ch-table">
46 + <div className="ch-table-head">
47 + <strong>{state.label} · {TF_LABEL[state.tf]} · {rows.length} visible bars</strong>
48 + <div className="row" style={{ gap: 6 }}>
49 + <Button size="sm" onClick={download} disabled={!rows.length} data-testid="ch-table-csv">Export CSV</Button>
50 + <button type="button" className="icon-btn" aria-label="Close table" onClick={onClose}><CloseIcon /></button>
51 + </div>
52 + </div>
53 + <Table columns={columns} rows={rows} keyField="t" dense stickyFirst caption={`Visible ${TF_LABEL[state.tf]} bars of ${state.label}, newest first`} emptyText="No bars in view" />
54 + {rows.length >= max && <p className="muted small" style={{ margin: '8px 12px' }}>Showing the newest {max} visible bars — zoom in for the full list.</p>}
55 + </aside>
56 + )
57 +}
added hfmarketdata/web/src/pages/charts/BottomSheet.jsx +30 −0
@@ -0,0 +1,30 @@
1 +// Mobile bottom sheet (role=dialog): backdrop, Escape / swipe-down-ish close button, body scroll locked.
2 +import React, { useEffect, useRef } from 'react'
3 +import { CloseIcon } from '../../components/Icons.jsx'
4 +
5 +export default function BottomSheet({ open, onClose, title, children, testId = 'ch-sheet' }) {
6 + const ref = useRef(null)
7 + useEffect(() => {
8 + if (!open) return undefined
9 + const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
10 + document.addEventListener('keydown', onKey, true)
11 + const prev = document.body.style.overflow
12 + document.body.style.overflow = 'hidden'
13 + setTimeout(() => ref.current?.querySelector('button, input, [tabindex]')?.focus(), 0)
14 + return () => { document.removeEventListener('keydown', onKey, true); document.body.style.overflow = prev }
15 + }, [open, onClose])
16 + if (!open) return null
17 + return (
18 + <>
19 + <div className="ch-sheet-backdrop" onClick={onClose} />
20 + <div className="ch-sheet" role="dialog" aria-modal="true" aria-label={title} ref={ref} data-testid={testId}>
21 + <div className="ch-sheet-head">
22 + <span className="ch-sheet-grip" aria-hidden="true" />
23 + <strong>{title}</strong>
24 + <button type="button" className="icon-btn" aria-label="Close" onClick={onClose}><CloseIcon /></button>
25 + </div>
26 + <div className="ch-sheet-body">{children}</div>
27 + </div>
28 + </>
29 + )
30 +}
added hfmarketdata/web/src/pages/charts/ChartsPage.jsx +460 −0
@@ -0,0 +1,460 @@
1 +// /charts — full-screen charting: symbol search, timeframes, series types, indicators, comparisons, drawings,
2 +// infinite history, URL state, table view. The rendering engine is `src/charts/engine` (contract in
3 +// /tmp/hfmd-charts-contract.md): the page only calls its public API and never re-creates the chart on option changes.
4 +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5 +import { useLocation } from 'react-router-dom'
6 +import { useAuth } from '../../app/auth.jsx'
7 +import useTitle from '../../docs/useTitle.js'
8 +import useMediaQuery from '../../components/useMediaQuery.js'
9 +import { ErrorState, Skeleton } from '../../components/States.jsx'
10 +import Button from '../../components/Button.jsx'
11 +import { createChart } from '../../charts/engine/index.js'
12 +import { CONTRACT_ASSET, INITIAL_BARS, TF_LABEL, barsErrorMessage, getCached, loadBars, loadMaxRows, putCached } from '../../charts/data/bars.js'
13 +import { assetLabel, defaultAdjustment, loadContracts, loadRootSpec, parseContract, pushRecent, resolveSymbol, searchSymbols } from '../../charts/data/symbols.js'
14 +import { priceFormatFor, timezoneLabel } from '../../charts/data/session.js'
15 +import { initialState, parseSearch, readDrawings, readPrefs, toSearch, writeDrawings, writeLast, writePrefs } from '../../charts/data/state.js'
16 +import { defaultParams, indicatorDef } from './indicators.js'
17 +import { buildTheme, seriesColor } from './theme.js'
18 +import Toolbar from './Toolbar.jsx'
19 +import Legend from './Legend.jsx'
20 +import DrawingBar, { TOOL_KEYS } from './DrawingBar.jsx'
21 +import StatusBar from './StatusBar.jsx'
22 +import BarsTable from './BarsTable.jsx'
23 +import BottomSheet from './BottomSheet.jsx'
24 +import { AdjustmentPanel, ComparePanel, IndicatorsPanel, ScalePanel, SeriesTypePanel, SettingsPanel } from './panels.jsx'
25 +import { CameraIcon, ExpandIcon, TableIcon } from './icons.jsx'
26 +import CopyButton from '../../components/CopyButton.jsx'
27 +import './charts.css'
28 +
29 +let seq = 0
30 +const nextId = prefix => `${prefix}${++seq}-${Date.now().toString(36)}`
31 +const isDebug = () => import.meta.env.DEV || new URLSearchParams(window.location.search).get('debug') === '1'
32 +const isTyping = e => { const t = e.target?.tagName; return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || e.target?.isContentEditable }
33 +
34 +function nextColorIndex(state) {
35 + const used = new Set([...state.indicators.map(i => i.colorIndex), ...state.compares.map(c => c.colorIndex)].filter(x => x != null))
36 + for (let i = 0; i < 64; i++) if (!used.has(i)) return i
37 + return used.size
38 +}
39 +
40 +export default function ChartsPage() {
41 + const location = useLocation()
42 + const { user } = useAuth()
43 + const isMobile = useMediaQuery('(max-width: 640px)')
44 + const [state, setState] = useState(() => initialState(location.search))
45 + const [prefs, setPrefsState] = useState(readPrefs)
46 + const [apiKey, setApiKey] = useState('')
47 + const [status, setStatus] = useState({ phase: 'loading', count: 0, firstT: null, lastT: null, loadingOlder: false, startOfHistory: false, rate: null, error: null })
48 + const [hover, setHover] = useState(null)
49 + const [lastBars, setLastBars] = useState({ last: null, prev: null })
50 + const [indValues, setIndValues] = useState({})
51 + const [range, setRange] = useState(null)
52 + const [table, setTable] = useState(false)
53 + const [sheet, setSheet] = useState(null) // null | 'tools' | 'type' | 'indicators' | 'compare' | 'adjustment' | 'scale' | 'settings'
54 + const [drawbarOpen, setDrawbarOpen] = useState(false)
55 + const [fullscreen, setFullscreen] = useState(false)
56 + const [tool, setTool] = useState(null)
57 + const [hasDrawings, setHasDrawings] = useState(false)
58 + const [name, setName] = useState('')
59 + const [suggestions, setSuggestions] = useState([])
60 + const [decimals, setDecimals] = useState(2)
61 +
62 + const rootRef = useRef(null)
63 + const containerRef = useRef(null)
64 + const chartRef = useRef(null)
65 + const symbolRef = useRef(null)
66 + const stateRef = useRef(state)
67 + const abortRef = useRef(null)
68 + const olderRef = useRef({ inflight: false, done: false })
69 + const appliedInd = useRef(new Map())
70 + const appliedCmp = useRef(new Map())
71 + const themeRef = useRef(null)
72 + const lastSearchRef = useRef(null)
73 + const hoverRaf = useRef(0)
74 + stateRef.current = state
75 +
76 + const tz = timezoneLabel(state.asset)
77 + const authenticated = !!user || !!apiKey
78 + const theme = useMemo(() => (typeof document !== 'undefined' ? buildTheme({ colorblind: prefs.colorblind }) : null), [prefs.colorblind]) // eslint-disable-line react-hooks/exhaustive-deps
79 + themeRef.current = theme
80 +
81 + useTitle(`${state.label} ${TF_LABEL[state.tf]} · Charts`, `Interactive ${state.label} ${TF_LABEL[state.tf]} chart — candles, indicators, comparisons and drawings on HF Market Data's free market data API.`)
82 +
83 + // ---- chart lifecycle (once) --------------------------------------------------------------------------------
84 + useEffect(() => {
85 + const el = containerRef.current
86 + if (!el) return undefined
87 + const s = stateRef.current
88 + const chart = createChart(el, { theme: buildTheme({ colorblind: prefs.colorblind }), timeframe: s.tf, sessionLabel: timezoneLabel(s.asset), watermark: prefs.watermark ? `${s.label} · ${TF_LABEL[s.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches })
89 + chartRef.current = chart
90 + if (isDebug()) window.__hfmdChart = chart
91 + const offs = [
92 + chart.on('visibleRangeChange', r => {
93 + setRange(r)
94 + if (r.needMoreLeft) loadOlderRef.current()
95 + }),
96 + chart.on('crosshairMove', info => {
97 + cancelAnimationFrame(hoverRaf.current)
98 + hoverRaf.current = requestAnimationFrame(() => {
99 + if (!info) { setHover(null); return }
100 + const data = chart.getData()
101 + setHover({ bar: info.bar, prev: data[info.index - 1] || null, indicators: info.indicators || {}, compares: info.compares || {} })
102 + })
103 + }),
104 + chart.on('drawingsChange', list => { writeDrawings(stateRef.current, list); setHasDrawings(list.length > 0) }),
105 + chart.on('toolChange', t => setTool(t)),
106 + ]
107 + const onTheme = () => chart.setTheme(buildTheme({ colorblind: readPrefs().colorblind }))
108 + window.addEventListener('hfmd:theme', onTheme)
109 + return () => {
110 + offs.forEach(off => off())
111 + window.removeEventListener('hfmd:theme', onTheme)
112 + cancelAnimationFrame(hoverRaf.current)
113 + abortRef.current?.abort()
114 + chart.destroy()
115 + chartRef.current = null
116 + if (window.__hfmdChart === chart) delete window.__hfmdChart
117 + }
118 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
119 +
120 + // ---- helpers ------------------------------------------------------------------------------------------------------
121 + const refreshLastValues = useCallback(() => {
122 + const chart = chartRef.current
123 + if (!chart) return
124 + const data = chart.getData()
125 + setLastBars({ last: data[data.length - 1] || null, prev: data[data.length - 2] || null })
126 + const vals = {}
127 + for (const ind of chart.getIndicators()) {
128 + vals[ind.id] = {}
129 + for (const [k, arr] of Object.entries(ind.values || {})) vals[ind.id][k] = Array.isArray(arr) ? arr[arr.length - 1] : null
130 + }
131 + setIndValues(vals)
132 + }, [])
133 +
134 + const query = useMemo(() => ({ asset: state.asset, ticker: state.ticker, timeframe: state.tf, adjustment: state.adjustment || defaultAdjustment(state.asset) }), [state.asset, state.ticker, state.tf, state.adjustment])
135 + const queryRef = useRef(query)
136 + queryRef.current = query
137 +
138 + // ---- initial load per (asset, ticker, tf, adjustment) -----------------------------------------------------------
139 + useEffect(() => {
140 + const chart = chartRef.current
141 + if (!chart) return undefined
142 + abortRef.current?.abort()
143 + const ctrl = new AbortController()
144 + abortRef.current = ctrl
145 + olderRef.current = { inflight: false, done: false }
146 + setSuggestions([])
147 + chart.setTimeframe(query.timeframe)
148 + chart.setOptions({ sessionLabel: timezoneLabel(query.asset), watermark: prefs.watermark ? `${state.label} · ${TF_LABEL[query.timeframe]} · HF Market Data` : undefined })
149 + chart.setDrawings(readDrawings({ asset: query.asset, ticker: query.ticker, tf: query.timeframe }))
150 + setHasDrawings(readDrawings({ asset: query.asset, ticker: query.ticker, tf: query.timeframe }).length > 0)
151 + writeLast(state)
152 +
153 + const apply = (bars, extra) => {
154 + chart.setData(bars)
155 + chart.fitContent(false)
156 + const fmt = priceFormatFor(query.asset, bars, extra?.spec)
157 + setDecimals(fmt.decimals)
158 + chart.setOptions({ priceFormat: fmt })
159 + refreshLastValues()
160 + setStatus(st => ({ ...st, phase: 'ready', error: null, count: bars.length, firstT: bars[0]?.t ?? null, lastT: bars[bars.length - 1]?.t ?? null, startOfHistory: !!extra?.startOfHistory, rate: extra?.rate || st.rate }))
161 + olderRef.current.done = !!extra?.startOfHistory
162 + }
163 + // spec / name (futures) and contract metadata, best effort, cached
164 + let contractMeta = null
165 + const metaP = (async () => {
166 + try {
167 + if (query.asset === 'futures') { const spec = await loadRootSpec(query.ticker, { apiKey, signal: ctrl.signal }); if (spec) setName(spec.name || ''); return spec }
168 + if (query.asset === CONTRACT_ASSET) { const root = parseContract(query.ticker)?.root; if (root) { const list = await loadContracts(root, { apiKey, signal: ctrl.signal }); contractMeta = list.find(c => c.ticker === query.ticker) || null; setName(contractMeta?.name || ''); return contractMeta } }
169 + setName('')
170 + } catch { setName('') }
171 + return null
172 + })()
173 +
174 + const cached = getCached(query)
175 + if (cached?.bars?.length) { metaP.then(spec => apply(cached.bars, { spec, startOfHistory: cached.startOfHistory, rate: cached.rate })); return () => ctrl.abort() }
176 +
177 + setStatus(st => ({ ...st, phase: 'loading', error: null, loadingOlder: false, startOfHistory: false }))
178 + ;(async () => {
179 + try {
180 + const [{ maxRows }, spec] = await Promise.all([loadMaxRows({ apiKey }), metaP])
181 + const limit = Math.min(INITIAL_BARS[query.timeframe] || 1500, maxRows)
182 + const res = await loadBars({ ...query, limit, apiKey, signal: ctrl.signal, firstDate: contractMeta?.firstDate })
183 + if (ctrl.signal.aborted) return
184 + const entry = putCached(query, res.bars, { startOfHistory: res.complete, rate: res.rate })
185 + apply(entry.bars, { spec, startOfHistory: res.complete, rate: res.rate })
186 + } catch (e) {
187 + if (e?.name === 'AbortError' || ctrl.signal.aborted) return
188 + setStatus(st => ({ ...st, phase: 'error', error: e, rate: e.rate || st.rate }))
189 + if (e.kind === 'not_found') {
190 + searchSymbols(query.ticker.slice(0, Math.min(3, query.ticker.length)), { apiKey, signal: ctrl.signal, perGroup: 3 }).then(groups => { if (!ctrl.signal.aborted) setSuggestions(groups.flatMap(g => g.items).filter(it => it.ticker !== query.ticker).slice(0, 8)) }).catch(() => {})
191 + }
192 + }
193 + })()
194 + return () => ctrl.abort()
195 + }, [query, apiKey]) // eslint-disable-line react-hooks/exhaustive-deps
196 +
197 + // ---- infinite history ----------------------------------------------------------------------------------------
198 + const loadOlderRef = useRef(() => {})
199 + loadOlderRef.current = async () => {
200 + const chart = chartRef.current
201 + const q = queryRef.current
202 + if (!chart || olderRef.current.inflight || olderRef.current.done) return
203 + const data = chart.getData()
204 + if (!data.length) return
205 + olderRef.current.inflight = true
206 + setStatus(st => ({ ...st, loadingOlder: true }))
207 + const ctrl = abortRef.current
208 + try {
209 + const { maxRows } = await loadMaxRows({ apiKey })
210 + const limit = Math.min(INITIAL_BARS[q.timeframe] || 1500, maxRows)
211 + const res = await loadBars({ ...q, end: data[0].t, limit, apiKey, signal: ctrl?.signal })
212 + if (ctrl?.signal.aborted || queryRef.current !== q) return
213 + const older = res.bars.filter(b => b.t < data[0].t)
214 + putCached(q, older, { startOfHistory: res.complete, rate: res.rate })
215 + if (older.length) chart.prependData(older)
216 + if (res.complete) olderRef.current.done = true
217 + const all = chart.getData()
218 + setStatus(st => ({ ...st, loadingOlder: false, count: all.length, firstT: all[0]?.t ?? null, startOfHistory: res.complete, rate: res.rate || st.rate }))
219 + } catch (e) {
220 + if (e?.name === 'AbortError') return
221 + // a 429 / network error while paginating: stop quietly, keep what we have, surface the quota in the status bar
222 + olderRef.current.done = e.kind === 'not_found'
223 + setStatus(st => ({ ...st, loadingOlder: false, rate: e.rate || st.rate, olderError: e }))
224 + } finally {
225 + olderRef.current.inflight = false
226 + }
227 + }
228 +
229 + // ---- option sync (never re-creates the chart) -------------------------------------------------------------------
230 + const chartCall = (fn, deps) => useEffect(() => { const c = chartRef.current; if (c) fn(c) }, deps) // eslint-disable-line react-hooks/rules-of-hooks
231 + chartCall(c => c.setSeriesType(prefs.colorblind && state.type === 'candles' ? 'hollow' : state.type), [state.type, prefs.colorblind])
232 + chartCall(c => c.setPriceScale({ mode: state.compares.length ? 'percent' : state.scale, auto: prefs.autoScale }), [state.scale, state.compares.length, prefs.autoScale])
233 + chartCall(c => c.setVolume(state.volume), [state.volume])
234 + chartCall(c => { if (theme) c.setTheme(theme) }, [theme])
235 + chartCall(c => c.setCrosshair({ mode: prefs.magnet ? 'magnet' : 'normal', showLabels: true }), [prefs.magnet])
236 + chartCall(c => c.setOptions({ watermark: prefs.watermark ? `${state.label} · ${TF_LABEL[state.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion }), [prefs.watermark, prefs.reducedMotion, state.label, state.tf])
237 +
238 + // indicators reconciliation
239 + useEffect(() => {
240 + const c = chartRef.current
241 + if (!c || !theme) return
242 + const want = new Map(state.indicators.filter(i => i.id && i.colorIndex != null).map(i => [i.id, i]))
243 + for (const [id] of appliedInd.current) if (!want.has(id)) { c.removeIndicator(id); appliedInd.current.delete(id) }
244 + for (const [id, ind] of want) {
245 + const key = JSON.stringify(ind.params)
246 + const prev = appliedInd.current.get(id)
247 + if (!prev) { c.addIndicator({ id, type: ind.type, params: ind.params, pane: ind.pane, colors: [seriesColor(theme, ind.colorIndex ?? 0)] }); appliedInd.current.set(id, key) }
248 + else if (prev !== key) { c.updateIndicator(id, ind.params); appliedInd.current.set(id, key) }
249 + }
250 + refreshLastValues()
251 + }, [state.indicators, theme, refreshLastValues])
252 +
253 + // comparisons: load bars then overlay
254 + useEffect(() => {
255 + const c = chartRef.current
256 + if (!c || !theme) return
257 + const want = new Map(state.compares.filter(x => x.id && x.colorIndex != null).map(x => [x.id, x]))
258 + for (const [id, entry] of appliedCmp.current) if (!want.has(id)) { entry.ctrl?.abort(); c.removeCompare(id); appliedCmp.current.delete(id) }
259 + for (const [id, cmp] of want) {
260 + const key = `${cmp.asset}|${cmp.ticker}|${query.timeframe}`
261 + const prev = appliedCmp.current.get(id)
262 + if (prev?.key === key) continue
263 + prev?.ctrl?.abort()
264 + const ctrl = new AbortController()
265 + appliedCmp.current.set(id, { key, ctrl })
266 + ;(async () => {
267 + try {
268 + const { maxRows } = await loadMaxRows({ apiKey })
269 + const q = { asset: cmp.asset, ticker: cmp.ticker, timeframe: query.timeframe, adjustment: defaultAdjustment(cmp.asset) }
270 + const cached = getCached(q)
271 + const bars = cached?.bars?.length ? cached.bars : (await loadBars({ ...q, limit: Math.min(INITIAL_BARS[q.timeframe] || 1500, maxRows), apiKey, signal: ctrl.signal })).bars
272 + if (ctrl.signal.aborted) return
273 + if (!cached?.bars?.length) putCached(q, bars)
274 + c.addCompare(id, cmp.ticker, bars, seriesColor(theme, cmp.colorIndex ?? 0))
275 + setState(s => ({ ...s, compares: s.compares.map(x => (x.id === id ? { ...x, loading: false, error: null } : x)) }))
276 + } catch (e) {
277 + if (e?.name === 'AbortError') return
278 + setState(s => ({ ...s, compares: s.compares.map(x => (x.id === id ? { ...x, loading: false, error: e.kind === 'not_found' ? 'not found' : e.kind === 'rate_limit' ? 'rate limited' : 'error' } : x)) }))
279 + }
280 + })()
281 + }
282 + }, [state.compares, query.timeframe, theme, apiKey]) // eslint-disable-line react-hooks/exhaustive-deps
283 +
284 + // ---- URL sync (replaceState, debounced) + external navigation --------------------------------------------------
285 + useEffect(() => {
286 + const search = toSearch(state)
287 + const t = setTimeout(() => {
288 + if (window.location.pathname !== '/charts') return
289 + if (window.location.search !== search) { lastSearchRef.current = search; window.history.replaceState(window.history.state, '', `/charts${search}${window.location.hash}`) }
290 + }, 250)
291 + return () => clearTimeout(t)
292 + }, [state])
293 + useEffect(() => {
294 + if (location.search === lastSearchRef.current || !location.search) return
295 + const parsed = parseSearch(location.search)
296 + if (parsed.ticker && (parsed.ticker !== stateRef.current.ticker || (parsed.tf && parsed.tf !== stateRef.current.tf))) {
297 + setState(s => ({ ...s, ...parsed, label: parsed.ticker, indicators: (parsed.indicators || s.indicators).map(i => ({ ...i, id: i.id || nextId('ind'), colorIndex: i.colorIndex ?? 0 })), compares: s.compares }))
298 + }
299 + }, [location.search])
300 +
301 + // assign ids / colours to indicators & compares that came from the URL
302 + useEffect(() => {
303 + if (state.indicators.every(i => i.id && i.colorIndex != null) && state.compares.every(c => c.id && c.colorIndex != null)) return
304 + setState(s => {
305 + let next = { ...s, indicators: [], compares: [] }
306 + for (const i of s.indicators) { next.indicators.push({ ...i, id: i.id || nextId('ind'), colorIndex: i.colorIndex ?? nextColorIndex(next) }) }
307 + for (const c of s.compares) { next.compares.push({ ...c, id: c.id || nextId('cmp'), asset: c.asset || 'stock', colorIndex: c.colorIndex ?? nextColorIndex(next), loading: c.loading ?? true }) }
308 + return next
309 + })
310 + }, [state.indicators, state.compares])
311 +
312 + useEffect(() => { writePrefs(prefs) }, [prefs])
313 + useEffect(() => { pushRecent({ asset: state.asset, ticker: state.ticker, label: state.label }) }, [state.asset, state.ticker, state.label])
314 + useEffect(() => {
315 + const on = () => setFullscreen(!!document.fullscreenElement)
316 + document.addEventListener('fullscreenchange', on)
317 + return () => document.removeEventListener('fullscreenchange', on)
318 + }, [])
319 + useEffect(() => { if (!isMobile) setSheet(null) }, [isMobile])
320 +
321 + // ---- actions ------------------------------------------------------------------------------------------------------
322 + const setPrefs = useCallback(p => setPrefsState(p), [])
323 + const a = useMemo(() => ({
324 + pickSymbol: item => { if (!item?.ticker) return; setState(s => ({ ...s, asset: item.asset || 'stock', ticker: item.ticker, label: item.label || item.ticker, adjustment: item.asset === s.asset ? s.adjustment : '' })); setSheet(null) },
325 + setTf: tf => setState(s => ({ ...s, tf })),
326 + setType: type => { setState(s => ({ ...s, type })); setSheet(null) },
327 + setScale: scale => setState(s => ({ ...s, scale })),
328 + setVolume: volume => setState(s => ({ ...s, volume })),
329 + setAdjustment: adjustment => { setState(s => ({ ...s, adjustment })); setSheet(null) },
330 + addIndicator: type => {
331 + const def = indicatorDef(type)
332 + if (!def) return
333 + setState(s => ({ ...s, indicators: [...s.indicators, { id: nextId('ind'), type, params: defaultParams(def), pane: def.pane, colorIndex: nextColorIndex(s) }] }))
334 + setSheet(null)
335 + },
336 + updateIndicator: (id, params) => setState(s => ({ ...s, indicators: s.indicators.map(i => (i.id === id ? { ...i, params } : i)) })),
337 + removeIndicator: id => setState(s => ({ ...s, indicators: s.indicators.filter(i => i.id !== id) })),
338 + addCompare: item => {
339 + if (!item?.ticker) return
340 + setState(s => {
341 + if (s.compares.some(c => c.ticker === item.ticker && c.asset === (item.asset || 'stock'))) return s
342 + return { ...s, compares: [...s.compares, { id: nextId('cmp'), ticker: item.ticker, asset: item.asset || 'stock', colorIndex: nextColorIndex(s), loading: true }] }
343 + })
344 + },
345 + removeCompare: id => setState(s => ({ ...s, compares: s.compares.filter(c => c.id !== id) })),
346 + setPref: (k, v) => setPrefsState(p => ({ ...p, [k]: v })),
347 + setPrefs,
348 + setApiKey,
349 + toggleTable: () => setTable(t => !t),
350 + toggleDrawbar: () => setDrawbarOpen(v => !v),
351 + openSheet: () => setSheet('tools'),
352 + screenshot: async () => {
353 + const c = chartRef.current
354 + if (!c) return
355 + const s = stateRef.current
356 + const blob = await c.toPNG({ scale: 2, watermark: `${s.label} · ${TF_LABEL[s.tf]} · hfmarketdata.io` })
357 + if (!blob) return
358 + const url = URL.createObjectURL(blob)
359 + const link = document.createElement('a'); link.href = url; link.download = `${s.ticker}-${TF_LABEL[s.tf]}.png`; link.click()
360 + setTimeout(() => URL.revokeObjectURL(url), 1000)
361 + },
362 + toggleFullscreen: () => { if (document.fullscreenElement) document.exitFullscreen?.(); else rootRef.current?.requestFullscreen?.() },
363 + retry: () => setState(s => ({ ...s })),
364 + }), [setPrefs])
365 +
366 + const setToolAction = useCallback(t => { chartRef.current?.setDrawingTool(t); setTool(t) }, [])
367 +
368 + // ---- keyboard ------------------------------------------------------------------------------------------------------
369 + useEffect(() => {
370 + const onKey = e => {
371 + const c = chartRef.current
372 + if (!c) return
373 + const meta = e.metaKey || e.ctrlKey
374 + if ((e.key === '/' && !e.altKey) && (meta || !isTyping(e))) { e.preventDefault(); e.stopImmediatePropagation(); symbolRef.current?.focus(); return }
375 + if (isTyping(e)) return
376 + if (meta && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? c.redo() : c.undo(); return }
377 + if (meta && e.key.toLowerCase() === 'y') { e.preventDefault(); c.redo(); return }
378 + if (meta) return
379 + if (e.key === 'Escape') { if (tool) { setToolAction(null) } return }
380 + if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); c.deleteSelectedDrawing(); return }
381 + const k = e.key.toLowerCase()
382 + if (TOOL_KEYS[k] !== undefined && !e.shiftKey) { e.preventDefault(); setToolAction(tool === TOOL_KEYS[k] ? null : TOOL_KEYS[k]) }
383 + }
384 + window.addEventListener('keydown', onKey, true)
385 + return () => window.removeEventListener('keydown', onKey, true)
386 + }, [tool, setToolAction])
387 +
388 + // ---- render --------------------------------------------------------------------------------------------------------
389 + const err = status.error
390 + const chartData = chartRef.current?.getData() || []
391 + const showDrawbar = isMobile ? drawbarOpen : prefs.showDrawingBar !== false
392 + const stale = status.phase === 'loading' && status.count > 0
393 +
394 + return (
395 + <main className={`ch-page ${fullscreen ? 'is-fullscreen' : ''}`} ref={rootRef} data-testid="ch-page">
396 + <h1 className="sr-only">Charts — {state.label} {TF_LABEL[state.tf]}</h1>
397 + <Toolbar state={state} prefs={prefs} a={a} apiKey={apiKey} authenticated={authenticated} fullscreen={fullscreen} table={table} drawbarOpen={drawbarOpen} symbolRef={symbolRef} isMobile={isMobile} />
398 + <div className="ch-body">
399 + {showDrawbar && <DrawingBar tool={tool} onTool={setToolAction} onUndo={() => chartRef.current?.undo()} onRedo={() => chartRef.current?.redo()} onClear={() => chartRef.current?.clearDrawings()} magnet={prefs.magnet} onMagnet={v => a.setPref('magnet', v)} hasDrawings={hasDrawings} horizontal={isMobile} />}
400 + <div className={`ch-stage ${stale ? 'is-stale' : ''}`}>
401 + <div className="ch-canvas" ref={containerRef} data-testid="ch-canvas" aria-label={`${state.label} ${TF_LABEL[state.tf]} price chart`} role="img" />
402 + {status.phase !== 'error' && (
403 + <Legend state={state} name={name} hover={hover} lastBar={lastBars.last} prevBar={lastBars.prev} indValues={hover ? hover.indicators : indValues} cmpValues={hover?.compares} decimals={decimals} tz={tz} theme={theme || { series: [] }}
404 + onUpdateIndicator={a.updateIndicator} onRemoveIndicator={a.removeIndicator} onRemoveCompare={a.removeCompare} compact={isMobile} />
405 + )}
406 + {status.phase === 'loading' && status.count === 0 && (
407 + <div className="ch-skeleton" aria-busy="true" aria-label="Loading chart" data-testid="ch-skeleton">
408 + <div className="ch-skeleton-bars" aria-hidden="true">{Array.from({ length: 28 }, (_, i) => <span key={i} className="skeleton" style={{ height: `${25 + ((i * 37) % 55)}%` }} />)}</div>
409 + <Skeleton width="40%" height="12px" />
410 + </div>
411 + )}
412 + {status.phase === 'error' && err && (
413 + <div className="ch-overlay" data-testid="ch-error">
414 + {err.kind === 'rate_limit' ? (
415 + <ErrorState status={429} code={err.code} retryUntil={err.retryUntil} authenticated={authenticated} message={barsErrorMessage(err)} onRetry={a.retry} />
416 + ) : err.kind === 'not_found' ? (
417 + <ErrorState status={404} code={err.code || 'TICKER_NOT_FOUND'} title={`${state.label} not found`} message={`${state.label} is not in the ${assetLabel(state.asset)} dataset for ${TF_LABEL[state.tf]} bars.`}
418 + actions={<Button size="sm" variant="ghost" onClick={() => symbolRef.current?.focus()}>Search another symbol</Button>}>
419 + {suggestions.length > 0 && (
420 + <div className="ch-suggest" data-testid="ch-suggestions">
421 + <span className="muted small">Did you mean</span>
422 + {suggestions.map(s => <button key={`${s.asset}:${s.ticker}`} type="button" className="ch-chip mono" onClick={() => a.pickSymbol(s)}>{s.ticker}<small>{assetLabel(s.asset)}</small></button>)}
423 + </div>
424 + )}
425 + </ErrorState>
426 + ) : (
427 + <ErrorState status={err.status || 0} code={err.code} message={barsErrorMessage(err)} onRetry={a.retry} />
428 + )}
429 + </div>
430 + )}
431 + </div>
432 + {table && <BarsTable bars={chartData} range={range} state={state} decimals={decimals} tz={tz} onClose={() => setTable(false)} />}
433 + </div>
434 + <StatusBar status={status} state={state} tz={tz} authenticated={authenticated} compact={isMobile} />
435 +
436 + <BottomSheet open={sheet === 'tools'} onClose={() => setSheet(null)} title="Chart tools">
437 + <div className="ch-sheet-grid">
438 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('type')}>Series type<span className="muted">{state.type}</span></button>
439 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('indicators')} data-testid="ch-sheet-indicators">Indicators<span className="muted">{state.indicators.length || 'none'}</span></button>
440 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('compare')}>Compare<span className="muted">{state.compares.length || 'none'}</span></button>
441 + {ADJ_ASSETS.has(state.asset) && <button type="button" className="ch-sheet-btn" onClick={() => setSheet('adjustment')}>Adjustment<span className="muted">{state.adjustment || 'default'}</span></button>}
442 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('scale')}>Price scale<span className="muted">{state.scale}</span></button>
443 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('settings')}>Settings<span className="muted">volume, colours…</span></button>
444 + <button type="button" className="ch-sheet-btn" onClick={() => { setTable(true); setSheet(null) }}><TableIcon /> Table of visible bars</button>
445 + <button type="button" className="ch-sheet-btn" onClick={() => { a.screenshot(); setSheet(null) }}><CameraIcon /> Screenshot (PNG)</button>
446 + <button type="button" className="ch-sheet-btn" onClick={() => { a.toggleFullscreen(); setSheet(null) }}><ExpandIcon /> Fullscreen</button>
447 + <CopyButton text={() => window.location.href} label="Copy share link" copiedLabel="Link copied" size="md" variant="secondary" className="ch-sheet-btn" />
448 + </div>
449 + </BottomSheet>
450 + <BottomSheet open={sheet === 'type'} onClose={() => setSheet(null)} title="Series type"><SeriesTypePanel value={state.type} onChange={a.setType} /></BottomSheet>
451 + <BottomSheet open={sheet === 'indicators'} onClose={() => setSheet(null)} title="Indicators"><IndicatorsPanel onAdd={a.addIndicator} active={state.indicators.map(i => i.type)} /></BottomSheet>
452 + <BottomSheet open={sheet === 'compare'} onClose={() => setSheet(null)} title="Compare"><ComparePanel compares={state.compares} onAdd={a.addCompare} onRemove={a.removeCompare} apiKey={apiKey} /></BottomSheet>
453 + <BottomSheet open={sheet === 'adjustment'} onClose={() => setSheet(null)} title="Adjustment"><AdjustmentPanel asset={state.asset} value={state.adjustment} onChange={a.setAdjustment} /></BottomSheet>
454 + <BottomSheet open={sheet === 'scale'} onClose={() => setSheet(null)} title="Price scale"><ScalePanel scale={state.scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={state.compares.length > 0} /></BottomSheet>
455 + <BottomSheet open={sheet === 'settings'} onClose={() => setSheet(null)} title="Settings"><SettingsPanel prefs={prefs} onPrefs={setPrefs} volume={state.volume} onVolume={a.setVolume} apiKey={apiKey} onApiKey={setApiKey} authenticated={authenticated} /></BottomSheet>
456 + </main>
457 + )
458 +}
459 +
460 +const ADJ_ASSETS = new Set(['stock', 'etf', 'futures'])
added hfmarketdata/web/src/pages/charts/DrawingBar.jsx +38 −0
@@ -0,0 +1,38 @@
1 +// Vertical drawing toolbar (left of the chart). Each tool is a toggle button (aria-pressed) with a tooltip and a
2 +// keyboard shortcut; undo / redo / clear at the bottom. On small screens it collapses behind a pencil button.
3 +import React from 'react'
4 +import { ArrowIcon, BrushIcon, ChannelIcon, CursorIcon, FibIcon, HLineIcon, MagnetIcon, MeasureIcon, RayIcon, RectIcon, RedoIcon, TextIcon, TrashIcon, TrendlineIcon, UndoIcon, VLineIcon } from './icons.jsx'
5 +
6 +export const TOOLS = [
7 + { id: null, label: 'Cursor', key: 'Esc', Icon: CursorIcon },
8 + { id: 'trendline', label: 'Trend line', key: 'T', Icon: TrendlineIcon },
9 + { id: 'ray', label: 'Ray', key: null, Icon: RayIcon },
10 + { id: 'hline', label: 'Horizontal line', key: 'H', Icon: HLineIcon },
11 + { id: 'vline', label: 'Vertical line', key: 'V', Icon: VLineIcon },
12 + { id: 'channel', label: 'Parallel channel', key: null, Icon: ChannelIcon },
13 + { id: 'rect', label: 'Rectangle', key: 'R', Icon: RectIcon },
14 + { id: 'fib', label: 'Fibonacci retracement', key: 'F', Icon: FibIcon },
15 + { id: 'measure', label: 'Measure', key: 'M', Icon: MeasureIcon },
16 + { id: 'arrow', label: 'Arrow', key: null, Icon: ArrowIcon },
17 + { id: 'text', label: 'Text', key: 'X', Icon: TextIcon },
18 + { id: 'brush', label: 'Brush', key: null, Icon: BrushIcon },
19 +]
20 +export const TOOL_KEYS = Object.fromEntries(TOOLS.filter(t => t.key && t.key.length === 1).map(t => [t.key.toLowerCase(), t.id]))
21 +
22 +export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magnet, onMagnet, hasDrawings, horizontal = false }) {
23 + return (
24 + <div className={`ch-drawbar ${horizontal ? 'is-horizontal' : ''}`} role="toolbar" aria-label="Drawing tools" aria-orientation={horizontal ? 'horizontal' : 'vertical'} data-testid="ch-drawbar">
25 + {TOOLS.map(({ id, label, key, Icon }) => (
26 + <button key={id || 'cursor'} type="button" className="ch-tool" aria-pressed={tool === id} aria-label={label} title={key ? `${label} (${key})` : label} data-tool={id || 'cursor'}
27 + onClick={() => onTool(tool === id && id !== null ? null : id)}>
28 + <Icon />
29 + </button>
30 + ))}
31 + <span className="ch-drawbar-sep" aria-hidden="true" />
32 + <button type="button" className="ch-tool" aria-pressed={magnet} aria-label="Magnet mode" title="Magnet: snap to OHLC" onClick={() => onMagnet(!magnet)}><MagnetIcon /></button>
33 + <button type="button" className="ch-tool" aria-label="Undo" title="Undo (Ctrl+Z)" onClick={onUndo}><UndoIcon /></button>
34 + <button type="button" className="ch-tool" aria-label="Redo" title="Redo (Ctrl+Y)" onClick={onRedo}><RedoIcon /></button>
35 + <button type="button" className="ch-tool ch-tool-danger" aria-label="Remove all drawings" title="Remove all drawings" onClick={onClear} disabled={!hasDrawings}><TrashIcon /></button>
36 + </div>
37 + )
38 +}
added hfmarketdata/web/src/pages/charts/Legend.jsx +112 −0
@@ -0,0 +1,112 @@
1 +// HTML legend overlaid on the chart (top-left): symbol · name · timeframe · session, OHLC + Δ of the hovered (or last)
2 +// bar, volume, one row per indicator (colour swatch + label + values, click → inline parameter editor) and per
3 +// comparison. Values are also announced through a throttled aria-live region so nothing depends on hovering.
4 +import React, { useEffect, useRef, useState } from 'react'
5 +import { CloseIcon } from '../../components/Icons.jsx'
6 +import { TF_LABEL } from '../../charts/data/bars.js'
7 +import { formatPrice, formatStampLabel, formatVolume } from '../../charts/data/session.js'
8 +import { indicatorDef, indicatorShortLabel } from './indicators.js'
9 +import { seriesColor } from './theme.js'
10 +
11 +function IndicatorEditor({ ind, onChange, onClose }) {
12 + const def = indicatorDef(ind.type)
13 + const ref = useRef(null)
14 + useEffect(() => {
15 + ref.current?.querySelector('input')?.focus()
16 + const onDoc = e => { if (!ref.current?.contains(e.target)) onClose() }
17 + const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
18 + document.addEventListener('pointerdown', onDoc)
19 + document.addEventListener('keydown', onKey, true)
20 + return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true) }
21 + }, [onClose])
22 + if (!def) return null
23 + return (
24 + <div className="ch-ind-editor" ref={ref} role="dialog" aria-label={`${def.label} settings`} data-testid="ch-ind-editor">
25 + <div className="ch-ind-editor-title">{def.label}</div>
26 + {def.params.map(([key, , min, max]) => (
27 + <label key={key} className="ch-ind-param">
28 + <span>{key}</span>
29 + <input type="number" className="mono" min={min} max={max} step={Number.isInteger(min) && Number.isInteger(ind.params[key]) ? 1 : 0.1} value={ind.params[key]}
30 + onChange={e => { const n = Number(e.target.value); if (Number.isFinite(n)) onChange({ ...ind.params, [key]: Math.min(max, Math.max(min, n)) }) }} />
31 + </label>
32 + ))}
33 + {!def.params.length && <p className="muted small" style={{ margin: 0 }}>No parameters.</p>}
34 + <button type="button" className="btn btn-sm" onClick={onClose}>Done</button>
35 + </div>
36 + )
37 +}
38 +
39 +const fmtVal = (v, d) => (v == null || !Number.isFinite(v) ? '—' : formatPrice(v, typeof d === 'number' ? Math.min(d, 4) : 'auto'))
40 +
41 +export default function Legend({ state, name, hover, lastBar, prevBar, indValues, cmpValues, decimals, tz, theme, onUpdateIndicator, onRemoveIndicator, onRemoveCompare, compact = false }) {
42 + const [editing, setEditing] = useState(null)
43 + const bar = hover?.bar || lastBar
44 + const prev = hover ? hover.prev : prevBar
45 + const ref = prev ? prev.c : bar?.o
46 + const delta = bar && ref != null ? bar.c - ref : null
47 + const pct = delta != null && ref ? (delta / ref) * 100 : null
48 + const dir = delta == null ? 'neutral' : delta >= 0 ? 'up' : 'down'
49 + const stamp = bar ? formatStampLabel(bar.t, state.tf, state.tf === '1day' ? '' : tz) : ''
50 +
51 + // throttled live text (≤ 1 update / 600 ms)
52 + const [live, setLive] = useState('')
53 + const liveRef = useRef({ t: 0, timer: 0 })
54 + useEffect(() => {
55 + if (!bar) return undefined
56 + const text = `${state.label} ${stamp}: open ${formatPrice(bar.o, decimals)}, high ${formatPrice(bar.h, decimals)}, low ${formatPrice(bar.l, decimals)}, close ${formatPrice(bar.c, decimals)}${pct != null ? `, ${pct >= 0 ? 'up' : 'down'} ${Math.abs(pct).toFixed(2)} percent` : ''}`
57 + const now = Date.now()
58 + const wait = Math.max(0, 600 - (now - liveRef.current.t))
59 + clearTimeout(liveRef.current.timer)
60 + liveRef.current.timer = setTimeout(() => { liveRef.current.t = Date.now(); setLive(text) }, wait)
61 + return () => clearTimeout(liveRef.current.timer)
62 + }, [bar, stamp, decimals, pct, state.label])
63 +
64 + return (
65 + <div className={`ch-legend ${compact ? 'is-compact' : ''}`} data-testid="ch-legend">
66 + <div className="ch-legend-head">
67 + <span className="ch-legend-sym mono">{state.label}</span>
68 + {name && <span className="ch-legend-name">{name}</span>}
69 + <span className="ch-legend-tf">{TF_LABEL[state.tf]}</span>
70 + <span className="ch-legend-tz" title={tz === 'ET' ? 'US/Eastern wall-clock stamps' : 'UTC stamps'}>{tz}</span>
71 + {state.adjustment && <span className="ch-legend-adj">{state.adjustment}</span>}
72 + </div>
73 + {bar ? (
74 + <div className="ch-legend-ohlc mono" data-testid="ch-legend-ohlc">
75 + <span className="ch-legend-stamp">{stamp}</span>
76 + <span>O <b className={dir}>{formatPrice(bar.o, decimals)}</b></span>
77 + <span>H <b className={dir}>{formatPrice(bar.h, decimals)}</b></span>
78 + <span>L <b className={dir}>{formatPrice(bar.l, decimals)}</b></span>
79 + <span>C <b className={dir}>{formatPrice(bar.c, decimals)}</b></span>
80 + {delta != null && <span className={`ch-legend-delta ${dir}`}>{delta >= 0 ? '+' : ''}{formatPrice(delta, decimals)} ({pct >= 0 ? '+' : ''}{pct.toFixed(2)}%)</span>}
81 + {bar.v != null && <span>Vol <b>{formatVolume(bar.v)}</b></span>}
82 + {bar.oi != null && <span>OI <b>{formatVolume(bar.oi)}</b></span>}
83 + </div>
84 + ) : <div className="ch-legend-ohlc mono muted">Loading…</div>}
85 + {state.indicators.map(ind => {
86 + const def = indicatorDef(ind.type)
87 + const color = seriesColor(theme, ind.colorIndex ?? 0)
88 + const vals = indValues?.[ind.id] || {}
89 + return (
90 + <div key={ind.id} className="ch-legend-row" data-testid="ch-legend-ind">
91 + <span className="ch-swatch" style={{ background: color }} aria-hidden="true" />
92 + <button type="button" className="ch-legend-btn" onClick={() => setEditing(editing === ind.id ? null : ind.id)} aria-expanded={editing === ind.id} title="Edit parameters">{indicatorShortLabel(ind)}</button>
93 + <span className="ch-legend-vals mono">
94 + {(def?.outputs || ['value']).map(k => <span key={k}>{def?.outputs.length > 1 && <i>{k} </i>}{fmtVal(vals[k], decimals)}</span>)}
95 + </span>
96 + <button type="button" className="ch-legend-x" aria-label={`Remove ${indicatorShortLabel(ind)}`} onClick={() => onRemoveIndicator(ind.id)}><CloseIcon /></button>
97 + {editing === ind.id && <IndicatorEditor ind={ind} onChange={p => onUpdateIndicator(ind.id, p)} onClose={() => setEditing(null)} />}
98 + </div>
99 + )
100 + })}
101 + {state.compares.map(c => (
102 + <div key={c.id} className="ch-legend-row" data-testid="ch-legend-cmp">
103 + <span className="ch-swatch" style={{ background: seriesColor(theme, c.colorIndex ?? 0) }} aria-hidden="true" />
104 + <span className="ch-legend-btn mono">{c.ticker}</span>
105 + <span className="ch-legend-vals mono">{c.loading ? 'loading…' : c.error ? <span className="down">{c.error}</span> : cmpValues?.[c.id] != null ? `${cmpValues[c.id] >= 0 ? '+' : ''}${cmpValues[c.id].toFixed(2)}%` : ''}</span>
106 + <button type="button" className="ch-legend-x" aria-label={`Remove comparison ${c.ticker}`} onClick={() => onRemoveCompare(c.id)}><CloseIcon /></button>
107 + </div>
108 + ))}
109 + <span className="sr-only" aria-live="polite" aria-atomic="true">{live}</span>
110 + </div>
111 + )
112 +}
added hfmarketdata/web/src/pages/charts/Menu.jsx +81 −0
@@ -0,0 +1,81 @@
1 +// Accessible dropdown for the chart toolbar: <Menu label icon> children </Menu>. The trigger carries aria-expanded /
2 +// aria-haspopup; the popover closes on Escape, outside click or item selection; ↑ ↓ Home End move between items.
3 +// <MenuItem> renders role=menuitem (or menuitemradio when `checked` is a boolean) — content stays free-form.
4 +import React, { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
5 +import { ChevronDownIcon } from '../../components/Icons.jsx'
6 +
7 +const Ctx = createContext({ close: () => {} })
8 +export const useMenu = () => useContext(Ctx)
9 +
10 +export default function Menu({ label, icon, value, children, align = 'start', className = '', wide = false, testId, title, hideLabelOnNarrow = false, onOpenChange }) {
11 + const [open, setOpen] = useState(false)
12 + const id = useId()
13 + const wrapRef = useRef(null)
14 + const btnRef = useRef(null)
15 + const close = useCallback((focusTrigger = false) => { setOpen(false); onOpenChange?.(false); if (focusTrigger) btnRef.current?.focus() }, [onOpenChange])
16 +
17 + useEffect(() => {
18 + if (!open) return undefined
19 + const onDoc = e => { if (!wrapRef.current?.contains(e.target)) close() }
20 + const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); close(true) } }
21 + document.addEventListener('pointerdown', onDoc)
22 + document.addEventListener('keydown', onKey, true)
23 + // focus first interactive element of the popover
24 + const t = setTimeout(() => wrapRef.current?.querySelector('.ch-pop [role^="menuitem"], .ch-pop input, .ch-pop button')?.focus(), 0)
25 + return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true); clearTimeout(t) }
26 + }, [open, close])
27 +
28 + const onKeyNav = e => {
29 + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
30 + const items = [...(wrapRef.current?.querySelectorAll('.ch-pop [role^="menuitem"]:not([disabled])') || [])]
31 + if (!items.length) return
32 + if (e.target.tagName === 'INPUT' && (e.key === 'Home' || e.key === 'End')) return
33 + e.preventDefault()
34 + const i = items.indexOf(document.activeElement)
35 + const next = e.key === 'ArrowDown' ? items[(i + 1) % items.length] : e.key === 'ArrowUp' ? items[(i - 1 + items.length) % items.length] : e.key === 'Home' ? items[0] : items[items.length - 1]
36 + next?.focus()
37 + }
38 +
39 + return (
40 + <div className={`ch-menu ${className}`} ref={wrapRef} onKeyDown={onKeyNav}>
41 + <button ref={btnRef} type="button" className={`ch-tb-btn ${open ? 'is-open' : ''} ${hideLabelOnNarrow ? 'ch-hide-label' : ''}`} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
42 + onClick={() => { setOpen(o => { onOpenChange?.(!o); return !o }) }} data-testid={testId} title={title || label}>
43 + {icon}
44 + {(label || value) && <span className="ch-tb-label">{value ?? label}</span>}
45 + <ChevronDownIcon className="ch-chev" />
46 + </button>
47 + {open && (
48 + <Ctx.Provider value={{ close }}>
49 + <div id={id} role="menu" aria-label={label} className={`ch-pop ${align === 'end' ? 'is-end' : ''} ${wide ? 'is-wide' : ''}`}>
50 + {typeof children === 'function' ? children(close) : children}
51 + </div>
52 + </Ctx.Provider>
53 + )}
54 + </div>
55 + )
56 +}
57 +
58 +/** Menu item. `checked` boolean → menuitemradio with aria-checked; `keepOpen` prevents closing on select. */
59 +export function MenuItem({ children, onSelect, checked, disabled, keepOpen = false, icon, hint, className = '', testId, ...rest }) {
60 + const { close } = useMenu()
61 + const radio = typeof checked === 'boolean'
62 + return (
63 + <button type="button" role={radio ? 'menuitemradio' : 'menuitem'} aria-checked={radio ? checked : undefined} disabled={disabled} tabIndex={-1}
64 + className={`ch-item ${checked ? 'is-checked' : ''} ${className}`} data-testid={testId}
65 + onClick={() => { onSelect?.(); if (!keepOpen) close() }} {...rest}>
66 + {icon && <span className="ch-item-icon">{icon}</span>}
67 + <span className="ch-item-body">{children}</span>
68 + {hint && <span className="ch-item-hint">{hint}</span>}
69 + {radio && <span className="ch-item-check" aria-hidden="true">{checked ? '✓' : ''}</span>}
70 + </button>
71 + )
72 +}
73 +
74 +export function MenuSection({ title, children }) {
75 + return (
76 + <div className="ch-section" role="group" aria-label={title}>
77 + {title && <div className="ch-section-title">{title}</div>}
78 + {children}
79 + </div>
80 + )
81 +}
added hfmarketdata/web/src/pages/charts/StatusBar.jsx +28 −0
@@ -0,0 +1,28 @@
1 +// Bottom status strip: bars loaded, covered range, "loading older…" / "start of history", quota left (from the
2 +// X-RateLimit-* headers of the last response) with a sign-in CTA for keyless visitors, and the session time zone.
3 +import React from 'react'
4 +import { Link } from 'react-router-dom'
5 +import { useCountdown, fmtDuration } from '../../components/States.jsx'
6 +import { formatStampLabel } from '../../charts/data/session.js'
7 +
8 +export default function StatusBar({ status, state, tz, authenticated, compact = false }) {
9 + const { count, firstT, lastT, loadingOlder, startOfHistory, rate, phase } = status
10 + const left = useCountdown(rate?.reset || null)
11 + const range = firstT != null && lastT != null ? `${formatStampLabel(firstT, state.tf, '')} → ${formatStampLabel(lastT, state.tf, '')}` : ''
12 + return (
13 + <div className="ch-status" role="status" aria-label="Chart status" data-testid="ch-status">
14 + <span className="ch-status-item mono" data-testid="ch-status-count">{count ? `${count.toLocaleString('en-US')} bars` : phase === 'loading' ? 'Loading…' : '—'}</span>
15 + {range && !compact && <span className="ch-status-item ch-status-range mono" title="Loaded range (wall-clock stamps)">{range}</span>}
16 + {loadingOlder && <span className="ch-status-item ch-status-busy" data-testid="ch-status-older"><span className="ch-spin" aria-hidden="true" /> loading older…</span>}
17 + {startOfHistory && !loadingOlder && <span className="ch-status-item muted" data-testid="ch-status-start">start of history</span>}
18 + <span className="ch-status-spacer" />
19 + {rate?.limitRequests != null && (
20 + <span className="ch-status-item ch-status-quota" title={rate.reset ? `Window resets in ${fmtDuration(left)}` : undefined} data-testid="ch-status-quota">
21 + <span className="mono">{rate.remainingRequests ?? '?'}/{rate.limitRequests}</span> req left
22 + {!authenticated && <Link to="/signin?next=/charts" className="ch-status-cta">Sign in for 120 req/min</Link>}
23 + </span>
24 + )}
25 + <span className="ch-status-item ch-status-tz" title={tz === 'ET' ? 'Timestamps are US/Eastern wall-clock (exchange time)' : 'Timestamps are UTC'}>{tz}</span>
26 + </div>
27 + )
28 +}
added hfmarketdata/web/src/pages/charts/SymbolSearch.jsx +100 −0
@@ -0,0 +1,100 @@
1 +// Symbol combobox: shows the current symbol, searches every asset list as you type (prefix first, then substring),
2 +// groups results by asset, lists recents when empty. ↑ ↓ Enter Esc; `/` or ⌘/ focuses it from anywhere on the page.
3 +import React, { useEffect, useId, useImperativeHandle, useRef, useState, forwardRef } from 'react'
4 +import { SearchIcon } from '../../components/Icons.jsx'
5 +import { ASSETS, assetLabel, getRecents, resolveSymbol, searchSymbols } from '../../charts/data/symbols.js'
6 +
7 +const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey, compact = false, placeholder = 'Symbol', autoFocus = false, id: idProp }, ref) {
8 + const [text, setText] = useState('')
9 + const [open, setOpen] = useState(false)
10 + const [groups, setGroups] = useState([])
11 + const [active, setActive] = useState(0)
12 + const [busy, setBusy] = useState(false)
13 + const inputRef = useRef(null)
14 + const wrapRef = useRef(null)
15 + const abortRef = useRef(null)
16 + const listId = useId()
17 + const inputId = idProp || `${listId}-input`
18 +
19 + useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
20 +
21 + const flat = groups.flatMap(g => g.items)
22 +
23 + useEffect(() => {
24 + if (!open) return undefined
25 + const q = text.trim()
26 + if (!q) { setGroups(getRecents().length ? [{ asset: 'recent', items: getRecents() }] : []); setActive(0); return undefined }
27 + const t = setTimeout(async () => {
28 + abortRef.current?.abort()
29 + const ctrl = new AbortController()
30 + abortRef.current = ctrl
31 + setBusy(true)
32 + try {
33 + const res = await searchSymbols(q, { apiKey, signal: ctrl.signal })
34 + if (!ctrl.signal.aborted) { setGroups(res); setActive(0) }
35 + } catch { /* aborted or offline */ } finally { if (!ctrl.signal.aborted) setBusy(false) }
36 + }, 120)
37 + return () => clearTimeout(t)
38 + }, [text, open, apiKey])
39 +
40 + useEffect(() => {
41 + if (!open) return undefined
42 + const onDoc = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
43 + document.addEventListener('pointerdown', onDoc)
44 + return () => document.removeEventListener('pointerdown', onDoc)
45 + }, [open])
46 +
47 + const pick = item => { onPick(item); setOpen(false); setText(''); inputRef.current?.blur() }
48 + const submit = async () => {
49 + const q = text.trim().toUpperCase()
50 + if (flat[active] && (flat[active].ticker === q || !q)) return pick(flat[active])
51 + if (!q) return
52 + if (flat[active] && flat[active].ticker.startsWith(q) && flat.length === 1) return pick(flat[active])
53 + setBusy(true)
54 + try { const r = await resolveSymbol(q, { apiKey }); if (r) pick(r) } finally { setBusy(false) }
55 + }
56 + const onKey = e => {
57 + if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive(a => (flat.length ? (a + 1) % flat.length : 0)) }
58 + else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => (flat.length ? (a - 1 + flat.length) % flat.length : 0)) }
59 + else if (e.key === 'Enter') { e.preventDefault(); submit() }
60 + else if (e.key === 'Escape') { e.stopPropagation(); setOpen(false); setText(''); inputRef.current?.blur() }
61 + }
62 +
63 + const expanded = open && flat.length > 0
64 + const shown = open ? text : ''
65 + let idx = -1
66 + return (
67 + <div className={`ch-symbol ${compact ? 'is-compact' : ''}`} ref={wrapRef}>
68 + <label htmlFor={inputId} className="sr-only">Symbol</label>
69 + <SearchIcon className="ch-symbol-icon" />
70 + <input ref={inputRef} id={inputId} type="text" className="ch-symbol-input mono" value={shown} placeholder={open || !current ? placeholder : ''} autoComplete="off" spellCheck={false} autoCapitalize="characters" autoFocus={autoFocus}
71 + role="combobox" aria-expanded={expanded} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={expanded && flat[active] ? `${listId}-${active}` : undefined}
72 + data-testid="ch-symbol-input"
73 + onChange={e => { setText(e.target.value.toUpperCase()); setOpen(true) }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
74 + {!open && current && <span className="ch-symbol-current mono" aria-hidden="true">{current}</span>}
75 + {busy ? <span className="ch-spin" aria-hidden="true" /> : !compact && <kbd className="ch-symbol-kbd" aria-hidden="true">/</kbd>}
76 + {expanded && (
77 + <ul id={listId} role="listbox" className="ch-symbol-list" aria-label="Symbols" data-testid="ch-symbol-list">
78 + {groups.map(g => (
79 + <li key={g.asset} role="presentation" className="ch-symbol-group">
80 + <div className="ch-symbol-group-title" role="presentation">{g.asset === 'recent' ? 'Recent' : ASSETS.find(a => a.id === g.asset)?.label || g.asset}</div>
81 + <ul role="group" aria-label={g.asset}>
82 + {g.items.map(it => { idx++; const i = idx; return (
83 + <li key={`${it.asset}:${it.ticker}`} id={`${listId}-${i}`} role="option" aria-selected={i === active} className={i === active ? 'active' : ''}
84 + onMouseDown={e => { e.preventDefault(); pick(it) }} onMouseEnter={() => setActive(i)}>
85 + <span className="mono ch-symbol-tk">{it.ticker}</span>
86 + {it.name && <span className="ch-symbol-name">{it.name}</span>}
87 + {g.asset === 'recent' && <span className="ch-symbol-asset">{assetLabel(it.asset)}</span>}
88 + </li>
89 + ) })}
90 + </ul>
91 + </li>
92 + ))}
93 + </ul>
94 + )}
95 + {open && !flat.length && text.trim() && !busy && <div className="ch-symbol-list ch-symbol-empty" role="status">No match yet — press Enter to try “{text.trim().toUpperCase()}”</div>}
96 + </div>
97 + )
98 +})
99 +
100 +export default SymbolSearch
added hfmarketdata/web/src/pages/charts/Toolbar.jsx +64 −0
@@ -0,0 +1,64 @@
1 +// Top toolbar (one row): symbol · timeframe · series type · indicators · compare · adjustment … scale · auto · magnet ·
2 +// settings · table · screenshot · fullscreen · share. Under 640 px only symbol + timeframe + "⋯" stay (the rest lives
3 +// in the bottom sheet rendered by the page).
4 +import React from 'react'
5 +import CopyButton from '../../components/CopyButton.jsx'
6 +import { TIMEFRAMES, TF_LABEL } from '../../charts/data/bars.js'
7 +import { ADJUSTMENTS } from '../../charts/data/symbols.js'
8 +import { SERIES_TYPES } from '../../charts/data/state.js'
9 +import Menu from './Menu.jsx'
10 +import SymbolSearch from './SymbolSearch.jsx'
11 +import { AdjustmentPanel, ComparePanel, IndicatorsPanel, ScalePanel, SeriesTypePanel, SettingsPanel } from './panels.jsx'
12 +import { AutoIcon, CalendarIcon, CameraIcon, CompareIcon, DotsIcon, ExpandIcon, GearIcon, IndicatorIcon, MagnetIcon, PencilIcon, ScaleIcon, SERIES_ICONS, ShrinkIcon, TableIcon } from './icons.jsx'
13 +
14 +export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullscreen, table, drawbarOpen, symbolRef, isMobile }) {
15 + const TypeIcon = SERIES_ICONS[state.type] || SERIES_ICONS.candles
16 + const typeLabel = SERIES_TYPES.find(([id]) => id === state.type)?.[1] || 'Candles'
17 + const scaleLabel = state.scale === 'log' ? 'Log' : state.scale === 'percent' ? '%' : 'Lin'
18 + const hasCompare = state.compares.length > 0
19 + const shareUrl = () => window.location.href
20 + return (
21 + <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar">
22 + <SymbolSearch ref={symbolRef} current={state.label} onPick={a.pickSymbol} apiKey={apiKey} compact={isMobile} />
23 + <div className="ch-seg" role="group" aria-label="Timeframe" data-testid="ch-tf">
24 + {TIMEFRAMES.map(tf => <button key={tf} type="button" className="ch-seg-btn" aria-pressed={state.tf === tf} onClick={() => a.setTf(tf)} data-testid={`ch-tf-${tf}`}>{TF_LABEL[tf]}</button>)}
25 + </div>
26 + {isMobile ? (
27 + <>
28 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={drawbarOpen} aria-label="Drawing tools" title="Drawing tools" onClick={a.toggleDrawbar}><PencilIcon /></button>
29 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-label="More tools" title="More tools" aria-haspopup="dialog" onClick={a.openSheet} data-testid="ch-more"><DotsIcon /></button>
30 + </>
31 + ) : (
32 + <>
33 + <Menu label="Series type" value={typeLabel} icon={<TypeIcon />} testId="ch-type-menu" hideLabelOnNarrow>
34 + <SeriesTypePanel value={state.type} onChange={a.setType} />
35 + </Menu>
36 + <Menu label="Indicators" icon={<IndicatorIcon />} testId="ch-ind-menu" wide hideLabelOnNarrow>
37 + <IndicatorsPanel onAdd={a.addIndicator} active={state.indicators.map(i => i.type)} />
38 + </Menu>
39 + <Menu label="Compare" icon={<CompareIcon />} testId="ch-cmp-menu" wide hideLabelOnNarrow value={hasCompare ? `Compare (${state.compares.length})` : undefined}>
40 + <ComparePanel compares={state.compares} onAdd={a.addCompare} onRemove={a.removeCompare} apiKey={apiKey} />
41 + </Menu>
42 + {ADJUSTMENTS[state.asset] && (
43 + <Menu label="Adjustment" icon={<CalendarIcon />} testId="ch-adj-menu" value={(state.adjustment || ADJUSTMENTS[state.asset][0][0]).replace('contin_', '').replace('adj_', '')} hideLabelOnNarrow>
44 + <AdjustmentPanel asset={state.asset} value={state.adjustment} onChange={a.setAdjustment} />
45 + </Menu>
46 + )}
47 + <span className="ch-tb-spacer" />
48 + <Menu label="Price scale" value={scaleLabel} icon={<ScaleIcon />} align="end" testId="ch-scale-menu">
49 + <ScalePanel scale={state.scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={hasCompare} />
50 + </Menu>
51 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={prefs.autoScale} aria-label="Auto-scale" title="Auto-scale price axis" onClick={() => a.setPref('autoScale', !prefs.autoScale)}><AutoIcon /></button>
52 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={prefs.magnet} aria-label="Magnet mode" title="Magnet: snap crosshair and drawings to OHLC" onClick={() => a.setPref('magnet', !prefs.magnet)} data-testid="ch-magnet"><MagnetIcon /></button>
53 + <Menu label="Settings" icon={<GearIcon />} align="end" testId="ch-settings-menu" wide>
54 + <SettingsPanel prefs={prefs} onPrefs={a.setPrefs} volume={state.volume} onVolume={a.setVolume} apiKey={apiKey} onApiKey={a.setApiKey} authenticated={authenticated} />
55 + </Menu>
56 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={table} aria-label="Table of visible bars" title="Table view (values without hovering)" onClick={a.toggleTable} data-testid="ch-table-btn"><TableIcon /></button>
57 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-label="Download PNG screenshot" title="Screenshot (PNG)" onClick={a.screenshot} data-testid="ch-screenshot"><CameraIcon /></button>
58 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={fullscreen} aria-label={fullscreen ? 'Exit fullscreen' : 'Fullscreen'} title="Fullscreen" onClick={a.toggleFullscreen}>{fullscreen ? <ShrinkIcon /> : <ExpandIcon />}</button>
59 + <CopyButton text={shareUrl} label="Share link" copiedLabel="Link copied" iconOnly size="md" variant="ghost" className="ch-tb-btn ch-tb-icon ch-share" data-testid="ch-share" />
60 + </>
61 + )}
62 + </div>
63 + )
64 +}
added hfmarketdata/web/src/pages/charts/charts.css +183 −0
@@ -0,0 +1,183 @@
1 +/* /charts — full-height layout under the 56 px header. Prefix: .ch- */
2 +.ch-page { display: flex; flex-direction: column; height: calc(100dvh - var(--topbar-h)); min-height: 420px; background: var(--bg); color: var(--fg); }
3 +.ch-page:fullscreen { height: 100vh; }
4 +.ch-page:fullscreen .ch-toolbar { padding-top: env(safe-area-inset-top); }
5 +
6 +/* ---- toolbar ------------------------------------------------------------------------------------------------ */
7 +.ch-toolbar { display: flex; align-items: center; gap: 4px; height: 48px; padding: 0 8px; border-bottom: 1px solid var(--line); flex: none; overflow: visible; }
8 +.ch-tb-spacer { flex: 1; }
9 +.ch-tb-btn { position: relative; display: inline-flex; align-items: center; gap: 6px; height: var(--tap); min-height: var(--tap); padding: 0 8px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; color: var(--fg-1); font: inherit; font-size: var(--fs-1); font-weight: 500; white-space: nowrap; cursor: pointer; }
10 +.ch-tb-btn:hover { background: var(--bg-2); color: var(--fg); }
11 +.ch-tb-btn[aria-pressed="true"], .ch-tb-btn.is-open { background: var(--accent-soft); color: var(--accent-strong); }
12 +[data-theme="light"] .ch-tb-btn[aria-pressed="true"], [data-theme="light"] .ch-tb-btn.is-open { color: var(--accent); }
13 +.ch-tb-btn svg { width: 18px; height: 18px; flex: none; }
14 +.ch-tb-btn .ch-chev { width: 12px; height: 12px; opacity: .6; }
15 +.ch-tb-icon { width: var(--tap); padding: 0; justify-content: center; }
16 +.ch-tb-btn.copy-btn { color: var(--fg-1); }
17 +.ch-tb-btn.copy-btn svg { width: 18px; height: 18px; }
18 +.ch-menu { position: relative; }
19 +
20 +.ch-seg { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--bg-1); flex: none; }
21 +.ch-seg-btn { min-width: 34px; height: calc(var(--tap) - 6px); padding: 0 6px; border: 0; border-radius: 4px; background: transparent; color: var(--fg-2); font: 600 var(--fs-0) var(--mono); cursor: pointer; }
22 +.ch-seg-btn:hover { color: var(--fg); background: var(--bg-2); }
23 +.ch-seg-btn[aria-pressed="true"] { background: var(--bg-3); color: var(--fg); }
24 +
25 +/* symbol combobox */
26 +.ch-symbol { position: relative; display: flex; align-items: center; gap: 6px; width: 230px; min-width: 150px; height: var(--tap); padding: 0 8px; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--bg-1); flex: none; }
27 +.ch-symbol:focus-within { border-color: var(--accent); }
28 +.ch-symbol-icon { width: 16px; height: 16px; color: var(--fg-3); flex: none; }
29 +.ch-symbol-input { flex: 1; min-width: 0; height: 100%; min-height: 0; padding: 0; border: 0; background: transparent; color: var(--fg); font-size: var(--fs-2); font-weight: 600; }
30 +.ch-symbol-input:focus-visible { outline: none; border-radius: 0; }
31 +.ch-symbol-current { position: absolute; left: 30px; top: 50%; transform: translateY(-50%); font-weight: 600; font-size: var(--fs-2); color: var(--fg); pointer-events: none; }
32 +.ch-symbol:focus-within .ch-symbol-current { display: none; }
33 +.ch-symbol-kbd { font-size: 10px; padding: 0 5px; color: var(--fg-3); }
34 +.ch-symbol.is-compact { width: 132px; min-width: 110px; }
35 +.ch-symbol-list { position: absolute; top: calc(100% + 4px); left: 0; z-index: var(--z-drawer); min-width: 340px; max-height: min(62vh, 520px); overflow: auto; margin: 0; padding: 4px; list-style: none; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
36 +.ch-symbol-list ul { list-style: none; margin: 0; padding: 0; }
37 +.ch-symbol-group-title { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
38 +.ch-symbol-list li[role="option"] { display: flex; align-items: center; gap: 10px; min-height: var(--tap); padding: 0 10px; border-radius: var(--r-sm); cursor: pointer; color: var(--fg-1); }
39 +.ch-symbol-list li[role="option"].active { background: var(--bg-2); color: var(--fg); }
40 +.ch-symbol-tk { font-weight: 600; min-width: 64px; }
41 +.ch-symbol-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-2); font-size: var(--fs-0); }
42 +.ch-symbol-asset { margin-left: auto; font-size: 11px; color: var(--fg-3); }
43 +.ch-symbol-empty { padding: 10px 12px; font-size: var(--fs-1); color: var(--fg-2); }
44 +.ch-spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: ch-spin .8s linear infinite; flex: none; }
45 +@keyframes ch-spin { to { transform: rotate(360deg); } }
46 +
47 +/* popovers */
48 +.ch-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: var(--z-drawer); min-width: 230px; max-height: min(70vh, 560px); overflow: auto; padding: 4px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
49 +.ch-pop.is-end { left: auto; right: 0; }
50 +.ch-pop.is-wide { min-width: 320px; }
51 +.ch-pop-search { padding: 6px 6px 4px; }
52 +.ch-pop-search .input { width: 100%; }
53 +.ch-section + .ch-section { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 4px; }
54 +.ch-section-title { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
55 +.ch-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: var(--tap); padding: 4px 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--fg-1); font: inherit; font-size: var(--fs-1); text-align: left; cursor: pointer; }
56 +.ch-item:hover, .ch-item:focus-visible { background: var(--bg-2); color: var(--fg); }
57 +.ch-item[disabled] { opacity: .45; cursor: not-allowed; }
58 +.ch-item.is-checked { color: var(--fg); }
59 +.ch-item-icon { display: inline-grid; place-items: center; width: 20px; color: var(--fg-2); }
60 +.ch-item-body { flex: 1; min-width: 0; }
61 +.ch-item-sub { display: block; font-size: 11px; color: var(--fg-3); }
62 +.ch-item-hint { margin-left: auto; font-size: 11px; color: var(--fg-3); }
63 +.ch-item-check { width: 14px; text-align: right; color: var(--accent); }
64 +.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 8px; }
65 +.ch-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px; border: 1px solid var(--line-2); border-radius: 999px; background: var(--bg-2); color: var(--fg); font-size: var(--fs-0); }
66 +.ch-chip small { color: var(--fg-3); font-size: 10px; }
67 +.ch-chip button { min-width: 20px; min-height: 20px; border: 0; background: transparent; color: var(--fg-2); font-size: 14px; line-height: 1; cursor: pointer; }
68 +.ch-chip button:hover { color: var(--fg); }
69 +.ch-suggest { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; margin: 4px 0 10px; }
70 +.ch-suggest .ch-chip { cursor: pointer; }
71 +.ch-suggest .ch-chip:hover { border-color: var(--line-3); }
72 +
73 +/* ---- body ---------------------------------------------------------------------------------------------------- */
74 +.ch-body { display: flex; flex: 1; min-height: 0; }
75 +.ch-drawbar { display: flex; flex-direction: column; align-items: center; gap: 2px; width: 40px; padding: 4px 0; border-right: 1px solid var(--line); flex: none; overflow-y: auto; scrollbar-width: none; }
76 +.ch-drawbar::-webkit-scrollbar { display: none; }
77 +.ch-drawbar-sep { width: 20px; height: 1px; margin: 4px 0; background: var(--line-2); flex: none; }
78 +.ch-tool { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--fg-2); cursor: pointer; flex: none; }
79 +.ch-tool:hover { background: var(--bg-2); color: var(--fg); }
80 +.ch-tool[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); }
81 +[data-theme="light"] .ch-tool[aria-pressed="true"] { color: var(--accent); }
82 +.ch-tool[disabled] { opacity: .4; cursor: not-allowed; }
83 +.ch-tool-danger:not([disabled]):hover { color: var(--danger); background: var(--danger-soft); }
84 +.ch-drawbar.is-horizontal { flex-direction: row; width: auto; height: 44px; padding: 0 4px; border-right: 0; border-bottom: 1px solid var(--line); overflow-x: auto; }
85 +.ch-drawbar.is-horizontal .ch-drawbar-sep { width: 1px; height: 20px; margin: 0 4px; }
86 +
87 +.ch-stage { position: relative; flex: 1; min-width: 0; min-height: 0; }
88 +.ch-canvas { position: absolute; inset: 0; transition: opacity .2s; }
89 +.ch-stage.is-stale .ch-canvas { opacity: .45; }
90 +.ch-skeleton { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 16px; padding: 60px 80px 40px 24px; background: var(--bg); z-index: 1; }
91 +.ch-skeleton-bars { display: flex; align-items: flex-end; gap: 6px; height: 60%; }
92 +.ch-skeleton-bars .skeleton { flex: 1; width: auto; border-radius: 2px; }
93 +.ch-overlay { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; padding: 20px; background: color-mix(in srgb, var(--bg) 82%, transparent); }
94 +.ch-overlay .error-state { max-width: 480px; width: 100%; }
95 +
96 +/* legend */
97 +.ch-legend { position: absolute; top: 8px; left: 10px; z-index: 2; display: flex; flex-direction: column; gap: 3px; max-width: calc(100% - 90px); font-size: var(--fs-0); pointer-events: none; }
98 +.ch-legend > * { pointer-events: auto; }
99 +.ch-legend-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
100 +.ch-legend-sym { font-size: var(--fs-3); font-weight: 700; color: var(--fg); letter-spacing: -.01em; }
101 +.ch-legend-name { color: var(--fg-2); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
102 +.ch-legend-tf, .ch-legend-tz, .ch-legend-adj { padding: 0 6px; border-radius: 4px; background: var(--bg-2); color: var(--fg-2); font-family: var(--mono); font-size: 11px; line-height: 18px; }
103 +.ch-legend-ohlc { display: flex; flex-wrap: wrap; gap: 10px; color: var(--fg-2); font-variant-numeric: tabular-nums; }
104 +.ch-legend-ohlc b { font-weight: 600; color: var(--fg); }
105 +.ch-legend-ohlc b.up, .ch-legend-delta.up { color: var(--up); }
106 +.ch-legend-ohlc b.down, .ch-legend-delta.down { color: var(--down); }
107 +.ch-legend-stamp { color: var(--fg-3); }
108 +.ch-legend-delta { font-weight: 600; }
109 +.ch-legend-row { position: relative; display: flex; align-items: center; gap: 6px; min-height: 22px; color: var(--fg-1); }
110 +.ch-swatch { width: 12px; height: 3px; border-radius: 2px; flex: none; }
111 +.ch-legend-btn { min-height: 22px; padding: 0 4px; border: 0; border-radius: 4px; background: transparent; color: var(--fg-1); font: inherit; font-size: var(--fs-0); font-weight: 600; cursor: pointer; }
112 +button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: var(--bg-2); color: var(--fg); }
113 +.ch-legend-vals { display: flex; gap: 8px; color: var(--fg-2); font-variant-numeric: tabular-nums; }
114 +.ch-legend-vals i { font-style: normal; color: var(--fg-3); }
115 +.ch-legend-x { display: grid; place-items: center; width: 22px; height: 22px; border: 0; border-radius: 4px; background: transparent; color: var(--fg-3); cursor: pointer; opacity: 0; }
116 +.ch-legend-x svg { width: 12px; height: 12px; }
117 +.ch-legend-row:hover .ch-legend-x, .ch-legend-x:focus-visible, .ch-legend.is-compact .ch-legend-x { opacity: 1; }
118 +.ch-legend-x:hover { color: var(--danger); background: var(--danger-soft); }
119 +.ch-ind-editor { position: absolute; top: 100%; left: 18px; z-index: 6; display: grid; gap: 8px; min-width: 200px; padding: 10px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
120 +.ch-ind-editor-title { font-weight: 600; color: var(--fg); }
121 +.ch-ind-param { display: grid; grid-template-columns: 1fr 90px; align-items: center; gap: 8px; color: var(--fg-2); }
122 +.ch-ind-param input { min-height: 32px; padding: 0 8px; }
123 +.ch-legend.is-compact { gap: 2px; }
124 +.ch-legend.is-compact .ch-legend-sym { font-size: var(--fs-2); }
125 +.ch-legend.is-compact .ch-legend-ohlc { gap: 6px; }
126 +.ch-legend.is-compact .ch-legend-name { display: none; }
127 +
128 +/* table */
129 +.ch-table { display: flex; flex-direction: column; width: 440px; max-width: 50%; border-left: 1px solid var(--line); background: var(--bg); flex: none; min-height: 0; }
130 +.ch-table-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: var(--fs-1); }
131 +.ch-table .table-wrap { flex: 1; min-height: 0; overflow: auto; border: 0; border-radius: 0; }
132 +.ch-table td, .ch-table th { font-size: var(--fs-0); }
133 +
134 +/* status */
135 +.ch-status { display: flex; align-items: center; gap: 12px; height: 30px; padding: 0 10px; border-top: 1px solid var(--line); color: var(--fg-2); font-size: var(--fs-0); flex: none; white-space: nowrap; overflow: hidden; }
136 +.ch-status-item { display: inline-flex; align-items: center; gap: 6px; }
137 +.ch-status-spacer { flex: 1; }
138 +.ch-status-quota { color: var(--fg-2); }
139 +.ch-status-cta { margin-left: 8px; color: var(--accent); font-weight: 500; }
140 +.ch-status-tz { font-family: var(--mono); color: var(--fg-3); }
141 +.ch-status-range { color: var(--fg-3); }
142 +
143 +/* mobile bottom sheet */
144 +.ch-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: rgba(0, 0, 0, .55); }
145 +.ch-sheet { position: fixed; left: 0; right: 0; bottom: 0; z-index: calc(var(--z-modal) + 1); display: flex; flex-direction: column; max-height: 85dvh; padding-bottom: env(safe-area-inset-bottom); background: var(--bg-1); border-top: 1px solid var(--line-2); border-radius: 16px 16px 0 0; box-shadow: var(--shadow-2); }
146 +.ch-sheet-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 8px 4px 16px; }
147 +.ch-sheet-grip { position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 2px; background: var(--line-3); }
148 +.ch-sheet-body { overflow: auto; padding: 4px 8px 12px; -webkit-overflow-scrolling: touch; }
149 +.ch-sheet-grid { display: grid; gap: 6px; }
150 +.ch-sheet-btn { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 48px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); color: var(--fg); font: inherit; font-weight: 500; text-align: left; cursor: pointer; }
151 +.ch-sheet-btn:hover { background: var(--bg-2); }
152 +.ch-sheet-btn .muted { font-size: var(--fs-0); font-weight: 400; }
153 +.ch-sheet-btn svg { width: 18px; height: 18px; margin-right: 6px; }
154 +
155 +/* ---- responsive ------------------------------------------------------------------------------------------------ */
156 +@media (max-width: 1200px) {
157 + .ch-hide-label .ch-tb-label, .ch-hide-label .ch-chev { display: none; }
158 + .ch-hide-label { width: var(--tap); padding: 0; justify-content: center; }
159 + .ch-symbol { width: 190px; }
160 + .ch-status-range { display: none; }
161 +}
162 +@media (max-width: 960px) {
163 + .ch-toolbar { height: 52px; gap: 2px; padding: 0 6px; }
164 + .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; }
165 + .ch-body { position: relative; }
166 + .ch-tool { width: 40px; height: 40px; }
167 + .ch-legend .ch-legend-x { opacity: 1; }
168 +}
169 +@media (max-width: 640px) {
170 + .ch-page { height: calc(100dvh - var(--topbar-h)); }
171 + .ch-toolbar { height: 52px; gap: 6px; padding: 0 8px; }
172 + .ch-symbol.is-compact { flex: 1; min-width: 96px; width: auto; }
173 + .ch-seg { flex: none; }
174 + .ch-seg-btn { min-width: 30px; padding: 0 4px; }
175 + .ch-body { flex-direction: column; }
176 + .ch-symbol-list { position: fixed; left: 8px; right: 8px; top: calc(var(--topbar-h) + 56px); min-width: 0; max-height: 55dvh; }
177 + .ch-legend { top: 6px; left: 8px; max-width: calc(100% - 76px); }
178 + .ch-legend-ohlc span:nth-child(n+7) { display: none; }
179 + .ch-status { gap: 8px; padding: 0 8px; }
180 + .ch-status-cta { display: none; }
181 + .ch-skeleton { padding: 40px 70px 30px 12px; }
182 +}
183 +@media (prefers-reduced-motion: reduce) { .ch-canvas { transition: none; } .ch-spin { animation-duration: 1.6s; } }
added hfmarketdata/web/src/pages/charts/icons.jsx +47 −0
@@ -0,0 +1,47 @@
1 +// Chart-page icons (20 px, stroke, currentColor). Drawing tools + toolbar actions not covered by components/Icons.jsx.
2 +import React from 'react'
3 +
4 +const I = ({ children, ...p }) => (
5 + <svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>{children}</svg>
6 +)
7 +
8 +export const CursorIcon = p => <I {...p}><path d="M5 3l11 7-5 1.5L8.5 17z" /></I>
9 +export const TrendlineIcon = p => <I {...p}><path d="M3.5 15.5 16.5 4.5" /><circle cx="3.5" cy="15.5" r="1.4" fill="currentColor" stroke="none" /><circle cx="16.5" cy="4.5" r="1.4" fill="currentColor" stroke="none" /></I>
10 +export const RayIcon = p => <I {...p}><path d="M4 16 17 3M14 3h3v3" /><circle cx="4" cy="16" r="1.4" fill="currentColor" stroke="none" /></I>
11 +export const HLineIcon = p => <I {...p}><path d="M2.5 10h15" /><circle cx="10" cy="10" r="1.5" fill="currentColor" stroke="none" /></I>
12 +export const VLineIcon = p => <I {...p}><path d="M10 2.5v15" /><circle cx="10" cy="10" r="1.5" fill="currentColor" stroke="none" /></I>
13 +export const RectIcon = p => <I {...p}><rect x="3.5" y="5" width="13" height="10" rx="1" /></I>
14 +export const FibIcon = p => <I {...p}><path d="M3 4h14M3 8h14M3 11h14M3 13h14M3 16h14" /></I>
15 +export const MeasureIcon = p => <I {...p}><path d="M3 14 14 3l3 3L6 17z" /><path d="m6.5 10.5 1.5 1.5M9 8l1.5 1.5M11.5 5.5 13 7" /></I>
16 +export const TextIcon = p => <I {...p}><path d="M4 5h12M10 5v11M7 16h6" /></I>
17 +export const ArrowIcon = p => <I {...p}><path d="M4 16 16 4M9 4h7v7" /></I>
18 +export const ChannelIcon = p => <I {...p}><path d="M3 12 13 3M7 17 17 8" /></I>
19 +export const BrushIcon = p => <I {...p}><path d="M3 16c3 0 3-3 5-4s3 1 5-1 3-5 4-7" /></I>
20 +export const UndoIcon = p => <I {...p}><path d="M7 5 3.5 8.5 7 12" /><path d="M3.5 8.5H12a4 4 0 0 1 0 8H9" /></I>
21 +export const RedoIcon = p => <I {...p}><path d="m13 5 3.5 3.5L13 12" /><path d="M16.5 8.5H8a4 4 0 0 0 0 8h3" /></I>
22 +export const TrashIcon = p => <I {...p}><path d="M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10M9 9v4M11 9v4" /></I>
23 +export const CameraIcon = p => <I {...p}><path d="M3 7h3l1.5-2h5L14 7h3v9H3z" /><circle cx="10" cy="11.5" r="2.5" /></I>
24 +export const ExpandIcon = p => <I {...p}><path d="M3 8V3h5M12 3h5v5M17 12v5h-5M8 17H3v-5" /></I>
25 +export const ShrinkIcon = p => <I {...p}><path d="M8 3v5H3M17 8h-5V3M12 17v-5h5M3 12h5v5" /></I>
26 +export const GearIcon = p => <I {...p}><circle cx="10" cy="10" r="2.6" /><path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" /></I>
27 +export const TableIcon = p => <I {...p}><rect x="3" y="4" width="14" height="12" rx="1" /><path d="M3 8h14M3 12h14M8 8v8" /></I>
28 +export const CompareIcon = p => <I {...p}><path d="M3 14c2-1 3-6 5-6s3 4 5 4 3-6 4-7" /><path d="M3 17h14" /></I>
29 +export const IndicatorIcon = p => <I {...p}><path d="M3 15 7 9l3 3 3-6 4 4" /><path d="M3 17h14" /></I>
30 +export const MagnetIcon = p => <I {...p}><path d="M6 3v7a4 4 0 0 0 8 0V3" /><path d="M4 3h4M12 3h4M6 7h2M12 7h2" strokeWidth="1.2" /></I>
31 +export const CandlesIcon = p => <I {...p}><path d="M6 3v3M6 13v4M14 3v2M14 15v2" /><rect x="4" y="6" width="4" height="7" rx=".5" /><rect x="12" y="5" width="4" height="10" rx=".5" /></I>
32 +export const HollowIcon = p => <I {...p}><path d="M6 3v3M6 13v4M14 3v2M14 15v2" /><rect x="4" y="6" width="4" height="7" rx=".5" /><rect x="12" y="5" width="4" height="10" rx=".5" fill="currentColor" /></I>
33 +export const OhlcIcon = p => <I {...p}><path d="M6 3v14M3.5 8H6M6 12h2.5M14 3v14M11.5 6H14M14 14h2.5" /></I>
34 +export const LineIcon = p => <I {...p}><path d="M3 14 7 8l3 3 4-7 3 4" /></I>
35 +export const AreaIcon = p => <I {...p}><path d="M3 15 7 9l3 3 4-7 3 4v6H3z" fill="currentColor" fillOpacity=".25" /></I>
36 +export const BaselineIcon = p => <I {...p}><path d="M3 10h14" strokeDasharray="2 2" /><path d="M3 13 7 7l3 3 4-6 3 5" /></I>
37 +export const HeikinIcon = p => <I {...p}><path d="M5 5v2M5 13v2M10 3v2M10 11v2M15 7v2M15 15v2" /><rect x="3.5" y="7" width="3" height="6" rx=".5" /><rect x="8.5" y="5" width="3" height="6" rx=".5" /><rect x="13.5" y="9" width="3" height="6" rx=".5" /></I>
38 +export const ColumnsIcon = p => <I {...p}><path d="M4 17V9M8 17V5M12 17v-7M16 17V7" strokeWidth="2.4" /></I>
39 +export const HlcIcon = p => <I {...p}><path d="M6 3v14M6 12h2.5M14 3v14M14 14h2.5" /></I>
40 +export const ScaleIcon = p => <I {...p}><path d="M4 3v14h13" /><path d="M7 13h1M7 10h1M7 7h1M7 4h1" /></I>
41 +export const CalendarIcon = p => <I {...p}><rect x="3" y="4" width="14" height="13" rx="1.5" /><path d="M3 8h14M7 2.5v3M13 2.5v3" /></I>
42 +export const DotsIcon = p => <I {...p}><circle cx="5" cy="10" r="1.4" fill="currentColor" stroke="none" /><circle cx="10" cy="10" r="1.4" fill="currentColor" stroke="none" /><circle cx="15" cy="10" r="1.4" fill="currentColor" stroke="none" /></I>
43 +export const PencilIcon = p => <I {...p}><path d="M3 17h4l9-9-4-4-9 9zM10 6l4 4" /></I>
44 +export const AutoIcon = p => <I {...p}><path d="M4 15 8 5l4 10M5.5 11.5h5" /><path d="M14 6v8M12.5 12.5 14 14l1.5-1.5M12.5 7.5 14 6l1.5 1.5" /></I>
45 +export const LinkIcon = p => <I {...p}><path d="M8.5 11.5a3 3 0 0 0 4.2 0l2.3-2.3a3 3 0 0 0-4.2-4.2l-.8.8M11.5 8.5a3 3 0 0 0-4.2 0L5 10.8a3 3 0 0 0 4.2 4.2l.8-.8" /></I>
46 +
47 +export const SERIES_ICONS = { candles: CandlesIcon, hollow: HollowIcon, ohlc: OhlcIcon, line: LineIcon, area: AreaIcon, baseline: BaselineIcon, heikin: HeikinIcon, columns: ColumnsIcon, hlc: HlcIcon }
added hfmarketdata/web/src/pages/charts/panels.jsx +94 −0
@@ -0,0 +1,94 @@
1 +// Content panels shared by the desktop toolbar menus and the mobile bottom sheet: series type, indicators (with
2 +// search), compare, adjustment, scale, settings. They render MenuItems (menuitem / menuitemradio) so they behave
3 +// inside a <Menu>, and plain lists inside the sheet (the Menu context defaults to a no-op close).
4 +import React, { useState } from 'react'
5 +import { Link } from 'react-router-dom'
6 +import { Input } from '../../components/Field.jsx'
7 +import { MenuItem, MenuSection } from './Menu.jsx'
8 +import { INDICATORS } from './indicators.js'
9 +import { SERIES_TYPES } from '../../charts/data/state.js'
10 +import { ADJUSTMENTS } from '../../charts/data/symbols.js'
11 +import { SERIES_ICONS } from './icons.jsx'
12 +import SymbolSearch from './SymbolSearch.jsx'
13 +
14 +export function SeriesTypePanel({ value, onChange }) {
15 + return (
16 + <MenuSection title="Series type">
17 + {SERIES_TYPES.map(([id, label]) => { const Icon = SERIES_ICONS[id]; return <MenuItem key={id} checked={value === id} onSelect={() => onChange(id)} icon={<Icon />} testId={`ch-type-${id}`}>{label}</MenuItem> })}
18 + </MenuSection>
19 + )
20 +}
21 +
22 +export function IndicatorsPanel({ onAdd, active = [] }) {
23 + const [q, setQ] = useState('')
24 + const list = INDICATORS.filter(i => !q || i.label.toLowerCase().includes(q.toLowerCase()) || i.type.includes(q.toLowerCase()))
25 + const overlays = list.filter(i => i.pane === 'main'), panes = list.filter(i => i.pane === 'new')
26 + return (
27 + <div className="ch-indpanel">
28 + <div className="ch-pop-search">
29 + <Input type="search" placeholder="Search indicators…" value={q} onChange={e => setQ(e.target.value)} aria-label="Search indicators" data-testid="ch-ind-search" />
30 + </div>
31 + {overlays.length > 0 && <MenuSection title="Overlays">{overlays.map(i => <MenuItem key={i.type} onSelect={() => onAdd(i.type)} hint={active.filter(a => a === i.type).length ? `×${active.filter(a => a === i.type).length}` : undefined} testId={`ch-ind-${i.type}`}>{i.label}</MenuItem>)}</MenuSection>}
32 + {panes.length > 0 && <MenuSection title="Oscillators (new pane)">{panes.map(i => <MenuItem key={i.type} onSelect={() => onAdd(i.type)} hint={active.filter(a => a === i.type).length ? `×${active.filter(a => a === i.type).length}` : undefined} testId={`ch-ind-${i.type}`}>{i.label}</MenuItem>)}</MenuSection>}
33 + {!list.length && <p className="muted small" style={{ padding: '8px 12px', margin: 0 }}>No indicator matches “{q}”.</p>}
34 + </div>
35 + )
36 +}
37 +
38 +export function ComparePanel({ compares, onAdd, onRemove, apiKey }) {
39 + return (
40 + <div className="ch-cmppanel">
41 + <MenuSection title="Compare (overlay, % change)">
42 + <div className="ch-pop-search"><SymbolSearch current="" onPick={onAdd} apiKey={apiKey} compact placeholder="Add symbol…" autoFocus id="ch-compare-input" /></div>
43 + {compares.length ? (
44 + <div className="ch-chips">
45 + {compares.map(c => <span key={c.id} className="ch-chip mono" data-testid="ch-cmp-chip">{c.ticker}<button type="button" aria-label={`Remove ${c.ticker}`} onClick={() => onRemove(c.id)}>×</button></span>)}
46 + </div>
47 + ) : <p className="muted small" style={{ padding: '4px 12px 8px', margin: 0 }}>Overlays another symbol normalised to % since the first visible bar.</p>}
48 + </MenuSection>
49 + </div>
50 + )
51 +}
52 +
53 +export function AdjustmentPanel({ asset, value, onChange }) {
54 + const opts = ADJUSTMENTS[asset]
55 + if (!opts) return <p className="muted small" style={{ padding: '8px 12px', margin: 0 }}>No adjustment variants for this asset.</p>
56 + const current = value || opts[0][0]
57 + return <MenuSection title="Price adjustment">{opts.map(([id, label]) => <MenuItem key={id} checked={current === id} onSelect={() => onChange(id === opts[0][0] ? '' : id)} testId={`ch-adj-${id}`}><span className="mono small">{id}</span><span className="ch-item-sub">{label}</span></MenuItem>)}</MenuSection>
58 +}
59 +
60 +export function ScalePanel({ scale, onScale, auto, onAuto, hasCompare }) {
61 + return (
62 + <>
63 + <MenuSection title="Price scale">
64 + {[['linear', 'Linear'], ['log', 'Logarithmic'], ['percent', 'Percent']].map(([id, label]) => <MenuItem key={id} checked={scale === id} onSelect={() => onScale(id)} disabled={hasCompare && id !== 'percent'} testId={`ch-scale-${id}`}>{label}</MenuItem>)}
65 + </MenuSection>
66 + <MenuSection>
67 + <MenuItem checked={auto} onSelect={() => onAuto(!auto)} keepOpen>Auto-scale</MenuItem>
68 + </MenuSection>
69 + </>
70 + )
71 +}
72 +
73 +export function SettingsPanel({ prefs, onPrefs, volume, onVolume, apiKey, onApiKey, authenticated }) {
74 + const set = (k, v) => onPrefs({ ...prefs, [k]: v })
75 + return (
76 + <div className="ch-settings">
77 + <MenuSection title="Display">
78 + <MenuItem checked={volume} onSelect={() => onVolume(!volume)} keepOpen testId="ch-set-volume">Volume histogram</MenuItem>
79 + <MenuItem checked={prefs.colorblind} onSelect={() => set('colorblind', !prefs.colorblind)} keepOpen testId="ch-set-cb">Colour-blind mode <span className="ch-item-sub">hollow candles · blue / orange</span></MenuItem>
80 + <MenuItem checked={prefs.watermark} onSelect={() => set('watermark', !prefs.watermark)} keepOpen>Watermark</MenuItem>
81 + <MenuItem checked={prefs.magnet} onSelect={() => set('magnet', !prefs.magnet)} keepOpen>Magnet crosshair</MenuItem>
82 + <MenuItem checked={prefs.reducedMotion} onSelect={() => set('reducedMotion', !prefs.reducedMotion)} keepOpen>Reduce motion</MenuItem>
83 + </MenuSection>
84 + <MenuSection title="API key (this tab only)">
85 + <div className="ch-pop-search">
86 + <Input type="password" mono placeholder="hfmd_live_…" value={apiKey} onChange={e => onApiKey(e.target.value.trim())} aria-label="API key, kept in memory only" autoComplete="off" />
87 + </div>
88 + <p className="muted small" style={{ padding: '0 12px 8px', margin: 0 }}>
89 + {authenticated ? 'Your session already applies your account tier.' : <>Kept in memory only, never stored. <Link to="/signup">Create a free account</Link> for 120 req/min and 50 000 bars per request.</>}
90 + </p>
91 + </MenuSection>
92 + </div>
93 + )
94 +}
added hfmarketdata/web/src/pages/charts/theme.js +42 −0
@@ -0,0 +1,42 @@
1 +// Engine Theme built from the site's CSS tokens (theme.css) so the chart follows dark/light automatically.
2 +// Series palette = the validated categorical colours of the engine contract (dataviz guide, 8 colours, fixed order).
3 +export const SERIES_DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
4 +export const SERIES_LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']
5 +/** Colour-blind preset: blue up / orange down (ΔE deutan-safe), combined with hollow candles by the page. */
6 +export const CB_UP = '#3987e5'
7 +export const CB_DOWN = '#d95926'
8 +
9 +function alpha(color, a) {
10 + const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
11 + if (!m) return color
12 + const n = parseInt(m[1], 16)
13 + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`
14 +}
15 +
16 +export function isLightTheme() {
17 + return typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') === 'light'
18 +}
19 +
20 +export function buildTheme({ colorblind = false } = {}) {
21 + const cs = getComputedStyle(document.documentElement)
22 + const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback
23 + const light = isLightTheme()
24 + const up = colorblind ? CB_UP : v('--up', '#2fbf71')
25 + const down = colorblind ? CB_DOWN : v('--down', '#e5484d')
26 + const fg = v('--fg', '#e8ebf1')
27 + const bg = v('--bg', '#0a0c10')
28 + return {
29 + bg, paneBorder: v('--line-2', '#313847'), grid: v('--line', '#232833'), gridStrong: v('--line-2', '#313847'),
30 + axisText: v('--fg-2', '#8f98a8'), axisLine: v('--line-2', '#313847'), crosshair: v('--fg-3', '#7d8797'),
31 + crosshairLabelBg: fg, crosshairLabelText: bg,
32 + up, down, upWick: up, downWick: down, neutral: v('--neutral', '#8b95a7'),
33 + volumeUp: alpha(up, 0.35), volumeDown: alpha(down, 0.35),
34 + series: light ? SERIES_LIGHT : SERIES_DARK,
35 + text: fg, textMuted: v('--fg-3', '#7d8797'), accent: v('--accent', '#34d399'),
36 + lastPriceUp: up, lastPriceDown: down,
37 + font: v('--sans', 'sans-serif'), mono: v('--mono', 'monospace'),
38 + drawing: v('--accent-2', '#60a5fa'), drawingHandle: fg, selection: alpha(v('--accent-2', '#60a5fa'), 0.25),
39 + }
40 +}
41 +
42 +export const seriesColor = (theme, i) => theme.series[i % theme.series.length]
modified tests/test_spa.py +1 −1
@@ -38,7 +38,7 @@ def spa_client(tmp_path_factory, app):
38 38
39 39
40 40 def test_root_and_known_routes_serve_shell_200(spa_client):
41 − for path in ("/", "/playground", "/signin", "/dashboard", "/dashboard/keys", "/admin/users", "/integrations/mcp", "/pricing"):
41 + for path in ("/", "/playground", "/charts", "/signin", "/dashboard", "/dashboard/keys", "/admin/users", "/integrations/mcp", "/pricing"):
42 42 r = spa_client.get(path)
43 43 assert r.status_code == 200, path
44 44 assert "text/html" in r.headers["content-type"] and "<div id=root>" in r.text
45 45