web: page /charts (barre d'outils, recherche de symbole, moteur, légende, barre d'état, dessins, table, mobile) + route, nav, prérendu, SPA_ROUTES
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 | |