SPB Git

Repository Public

Commit

web: /charts — e2e Playwright (fixtures OHLC synthétiques, 12 scénarios), correctifs mobile/échelle, stub moteur respecte le positionnement CSS

Simon-Pierre Boucher committed · Sep 7, 2026 parent e137c2c
5 changed files +298 −4 Browse files
added hfmarketdata/web/e2e/charts.spec.js +214 −0
@@ -0,0 +1,214 @@
1+// /charts — offline E2E against synthetic OHLC fixtures (e2e/fixtures/charts.js). The chart object is exposed as
2+// window.__hfmdChart only with ?debug=1 (or in dev), which the pagination test uses to move the viewport.
3+import { expect, test } from '@playwright/test'
4+import { mockAnon } from './mocks.js'
5+import { chartsHandler } from './fixtures/charts.js'
6+
7+async function setup(page, state = {}) {
8+ const handler = chartsHandler(state)
9+ await mockAnon(page, url => handler(url))
10+ return state
11+}
12+const legend = page => page.getByTestId('ch-legend')
13+const barsCalls = (state, pred = () => true) => state.calls.filter(pred)
14+
15+test.describe('charts', () => {
16+ test('loads AAPL 1D by default: one bars request, legend, status, URL', async ({ page }) => {
17+ const state = await setup(page)
18+ await page.goto('/charts')
19+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL')
20+ await expect(page.getByTestId('ch-legend-ohlc')).toContainText('O')
21+ await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/)
22+ await expect(page).toHaveURL(/\/charts\?s=AAPL$/)
23+ expect(barsCalls(state)).toHaveLength(1)
24+ expect(state.calls[0]).toMatchObject({ asset: 'stock', ticker: 'AAPL', tf: '1day' })
25+ expect(state.calls[0].search).toContain('order=desc')
26+ expect(state.calls[0].search).toContain('limit=1500')
27+ await expect(page.getByTestId('ch-status-quota')).toContainText('req left')
28+ await expect(page.getByTestId('ch-status-quota').getByRole('link', { name: /Sign in/ })).toBeVisible()
29+ await expect(page).toHaveTitle(/AAPL 1D · Charts/)
30+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-desktop.png' })
31+ })
32+
33+ test('timeframe switch keeps the previous render (no skeleton), updates URL, legend and request', async ({ page }) => {
34+ const state = await setup(page)
35+ await page.goto('/charts')
36+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
37+ await page.getByTestId('ch-tf-1min').click()
38+ await expect(page.getByTestId('ch-skeleton')).toHaveCount(0)
39+ await expect(page.getByTestId('ch-tf-1min')).toHaveAttribute('aria-pressed', 'true')
40+ await expect(page).toHaveURL(/tf=1min/)
41+ await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1m')
42+ await expect.poll(() => barsCalls(state, c => c.tf === '1min').length).toBe(1)
43+ expect(state.calls[1].search).toContain('limit=3000')
44+ await expect(page.getByTestId('ch-legend-ohlc')).toContainText('ET')
45+ })
46+
47+ test('URL state restores symbol, timeframe, type, indicators, scale and volume', async ({ page }) => {
48+ await setup(page)
49+ await page.goto('/charts?s=MSFT&tf=1hour&type=line&ind=sma:20,rsi:14&scale=log&vol=0&debug=1')
50+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('MSFT')
51+ await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1h')
52+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2)
53+ await expect(page.getByTestId('ch-legend-ind').nth(0)).toContainText('SMA 20')
54+ await expect(page.getByTestId('ch-legend-ind').nth(1)).toContainText('RSI 14')
55+ await expect(page.getByTestId('ch-type-menu')).toContainText('Line')
56+ await expect(page.getByTestId('ch-scale-menu')).toContainText('Log')
57+ await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane}`))).toEqual(['sma:20:main', 'rsi:14:new'])
58+ await expect(page).toHaveTitle(/MSFT 1h · Charts/)
59+ })
60+
61+ test('indicators: add from the searchable menu, edit a parameter inline, remove', async ({ page }) => {
62+ await setup(page)
63+ await page.goto('/charts?debug=1')
64+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
65+ await page.getByTestId('ch-ind-menu').click()
66+ await page.getByTestId('ch-ind-search').fill('expo')
67+ await expect(page.getByTestId('ch-ind-ema')).toBeVisible()
68+ await expect(page.getByTestId('ch-ind-sma')).toHaveCount(0)
69+ await page.getByTestId('ch-ind-ema').click()
70+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
71+ await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 20')
72+ await expect(page).toHaveURL(/ind=ema:20/)
73+ await page.getByTestId('ch-legend-ind').getByRole('button', { name: 'EMA 20', exact: true }).click()
74+ const editor = page.getByTestId('ch-ind-editor')
75+ await editor.getByRole('spinbutton').fill('50')
76+ await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 50')
77+ await expect(page).toHaveURL(/ind=ema:50/)
78+ await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators()[0].params.length)).toBe(50)
79+ await editor.getByRole('button', { name: 'Done' }).click()
80+ await page.getByRole('button', { name: 'Remove EMA 50' }).click()
81+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(0)
82+ await expect(page).not.toHaveURL(/ind=/)
83+ })
84+
85+ test('compare: adds a % overlay, chip + legend row, one request, URL cmp=', async ({ page }) => {
86+ const state = await setup(page)
87+ await page.goto('/charts')
88+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
89+ await page.getByTestId('ch-cmp-menu').click()
90+ const input = page.locator('#ch-compare-input')
91+ await input.fill('MSF')
92+ await page.getByTestId('ch-symbol-list').getByRole('option', { name: /MSFT/ }).click()
93+ await expect(page.getByTestId('ch-cmp-chip')).toHaveText(/MSFT/)
94+ await page.keyboard.press('Escape')
95+ await expect(page.getByTestId('ch-legend-cmp')).toContainText('MSFT')
96+ await expect(page).toHaveURL(/cmp=MSFT/)
97+ await expect.poll(() => barsCalls(state, c => c.ticker === 'MSFT').length).toBe(1)
98+ await expect(page.getByTestId('ch-scale-menu')).toContainText('%')
99+ await page.getByRole('button', { name: 'Remove comparison MSFT' }).click()
100+ await expect(page.getByTestId('ch-legend-cmp')).toHaveCount(0)
101+ })
102+
103+ test('infinite history: needMoreLeft loads older bars once, then "start of history"', async ({ page }) => {
104+ const state = await setup(page, { total: 2000 })
105+ await page.goto('/charts?debug=1')
106+ await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/)
107+ await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 120 }))
108+ await expect(page.getByTestId('ch-status-count')).toContainText(/2,000 bars/)
109+ await expect(page.getByTestId('ch-status-start')).toHaveText('start of history')
110+ expect(barsCalls(state)).toHaveLength(2)
111+ expect(state.calls[1].search).toMatch(/end=\d{4}-\d{2}-\d{2}/)
112+ const data = await page.evaluate(() => { const d = window.__hfmdChart.getData(); return { n: d.length, sorted: d.every((b, i) => i === 0 || d[i - 1].t < b.t) } })
113+ expect(data).toEqual({ n: 2000, sorted: true })
114+ // no further request once the start is reached
115+ await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 80 }))
116+ await page.waitForTimeout(300)
117+ expect(barsCalls(state)).toHaveLength(2)
118+ })
119+
120+ test('429 shows "rate limited — not down" with countdown and free-account CTA', async ({ page }) => {
121+ await setup(page, { rateLimit: true })
122+ await page.goto('/charts')
123+ const err = page.getByTestId('ch-error').locator('.error-state.is-429')
124+ await expect(err).toBeVisible()
125+ await expect(err).toContainText('Rate limited — not down')
126+ await expect(err.locator('.countdown')).toContainText(/min|s/)
127+ await expect(err.getByRole('link', { name: /Create free account/ })).toBeVisible()
128+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-429.png' })
129+ })
130+
131+ test('unknown symbol: 404 state with suggestions, picking one recovers', async ({ page }) => {
132+ await setup(page)
133+ await page.goto('/charts?s=AAPX')
134+ const err = page.getByTestId('ch-error')
135+ await expect(err).toContainText('AAPX not found')
136+ await expect(page.getByTestId('ch-suggestions')).toContainText('AAPL')
137+ await page.getByTestId('ch-suggestions').getByRole('button', { name: /^AAPL/ }).click()
138+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL')
139+ await expect(page.getByTestId('ch-error')).toHaveCount(0)
140+ })
141+
142+ test('symbol search: groups by asset, keyboard pick, recents; `/` focuses it', async ({ page }) => {
143+ await setup(page)
144+ await page.goto('/charts')
145+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
146+ await page.keyboard.press('/')
147+ await expect(page.getByTestId('ch-symbol-input')).toBeFocused()
148+ await expect(page.getByTestId('search-dialog')).toHaveCount(0)
149+ await page.keyboard.type('ES')
150+ const list = page.getByTestId('ch-symbol-list')
151+ await expect(list).toContainText('Futures (continuous)')
152+ await expect(list).toContainText('E-mini S&P 500')
153+ await list.getByRole('option', { name: /^ES E-mini/ }).click()
154+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('ES')
155+ await expect(page).toHaveURL(/s=ES&asset=futures/)
156+ await expect(page.getByTestId('ch-adj-menu')).toBeVisible()
157+ await page.getByTestId('ch-symbol-input').focus()
158+ await expect(list).toContainText('Recent')
159+ })
160+
161+ test('drawing tools: keyboard shortcuts toggle tools, table view lists visible bars with CSV export', async ({ page }) => {
162+ await setup(page)
163+ await page.goto('/charts')
164+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
165+ await page.keyboard.press('t')
166+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'true')
167+ await page.keyboard.press('Escape')
168+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'false')
169+ await page.getByTestId('ch-drawbar').getByRole('button', { name: 'Fibonacci retracement' }).click()
170+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="fib"]')).toHaveAttribute('aria-pressed', 'true')
171+ await page.getByTestId('ch-table-btn').click()
172+ const table = page.getByTestId('ch-table')
173+ await expect(table).toBeVisible()
174+ await expect(table.locator('tbody tr').first()).toContainText('2024-06-28')
175+ await expect(table.getByTestId('ch-table-csv')).toBeEnabled()
176+ await expect(page.getByTestId('ch-legend-ohlc')).toBeVisible()
177+ await page.getByRole('button', { name: 'Close table' }).click()
178+ await expect(table).toHaveCount(0)
179+ })
180+
181+ test('share copies the current URL; screenshot produces a PNG', async ({ page }) => {
182+ await setup(page)
183+ await page.goto('/charts?s=MSFT&tf=1hour')
184+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
185+ const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 })
186+ expect(blobSize).toBe(-1) // not exposed without ?debug=1
187+ await page.getByTestId('ch-share').click()
188+ await expect(page.getByTestId('ch-share')).toHaveAttribute('aria-label', 'Link copied')
189+ })
190+})
191+
192+test.describe('charts mobile', () => {
193+ test.use({ viewport: { width: 393, height: 851 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 })
194+
195+ test('condensed toolbar, bottom sheet with the rest, no horizontal overflow, 44 px targets', async ({ page }) => {
196+ await setup(page)
197+ await page.goto('/charts')
198+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
199+ await expect(page.getByTestId('ch-type-menu')).toHaveCount(0)
200+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
201+ const small = await page.evaluate(() => [...document.querySelectorAll('[data-testid="ch-toolbar"] button, [data-testid="ch-toolbar"] input')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 44 }).length)
202+ expect(small).toBe(0)
203+ const pageBox = await page.getByTestId('ch-page').boundingBox()
204+ expect(pageBox.height).toBeLessThanOrEqual(851 - 56 + 1)
205+ await page.getByTestId('ch-more').click()
206+ const sheet = page.getByTestId('ch-sheet')
207+ await expect(sheet).toBeVisible()
208+ await sheet.getByTestId('ch-sheet-indicators').click()
209+ await page.getByTestId('ch-ind-rsi').click()
210+ await expect(page.getByTestId('ch-legend-ind')).toContainText('RSI 14')
211+ await expect(page.getByTestId('ch-sheet')).toHaveCount(0)
212+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile.png' })
213+ })
214+})
added hfmarketdata/web/e2e/fixtures/charts.js +77 −0
@@ -0,0 +1,77 @@
1+// Synthetic OHLCV fixtures + route handler for the /charts E2E spec. Mirrors the real shapes:
2+// GET /v1/{asset}/tickers → { asset, timeframe, adjustment, count, tickers } · GET /v1/bars/{asset}/{ticker} (legacy
3+// envelope { count, data }, `order=desc`, `end` inclusive, `limit`) · GET /v1/futures/roots → { data, meta } ·
4+// GET /v1/limits → { data: { tiers, principal } }.
5+import { RATE } from '../mocks.js'
6+
7+const pad2 = n => String(n).padStart(2, '0')
8+const stamp = (ms, daily) => {
9+ const d = new Date(ms)
10+ const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
11+ return daily ? date : `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
12+}
13+const parse = s => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0)) : NaN }
14+
15+/** Deterministic random walk: `n` bars ending at 2024-06-28 (daily: trading days only; intraday: 09:30–16:00 ET). */
16+export function makeSeries(ticker, timeframe, n, seed = 7) {
17+ let x = seed
18+ const rnd = () => { x = (x * 1103515245 + 12345) & 0x7fffffff; return x / 0x7fffffff }
19+ const daily = timeframe === '1day'
20+ const step = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 }[timeframe]
21+ const out = []
22+ let t = daily ? Date.UTC(2024, 5, 28) : Date.UTC(2024, 5, 28, 15, 59)
23+ let px = ticker === 'MSFT' ? 420 : ticker === 'ES' ? 5400 : 190
24+ for (let i = 0; i < n; i++) {
25+ const d = new Date(t)
26+ const dow = d.getUTCDay()
27+ if (dow === 0 || dow === 6) { t -= daily ? step : (dow === 0 ? 2 : 1) * 86_400_000; i--; continue }
28+ if (!daily) { const mins = d.getUTCHours() * 60 + d.getUTCMinutes(); if (mins < 570) { t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1, 15, 59); i--; continue } if (mins >= 960) { t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 15, 59); i--; continue } }
29+ const o = px, c = +(px * (1 + (rnd() - 0.5) * 0.02)).toFixed(2)
30+ const h = +(Math.max(o, c) * (1 + rnd() * 0.005)).toFixed(2), l = +(Math.min(o, c) * (1 - rnd() * 0.005)).toFixed(2)
31+ out.push({ ticker, datetime: stamp(t, daily), open: +o.toFixed(2), high: h, low: l, close: c, volume: Math.round(1e6 + rnd() * 5e6) })
32+ px = c
33+ t -= step
34+ }
35+ return out.reverse()
36+}
37+
38+const TICKERS = { stock: ['AAPL', 'AAPG', 'AAP', 'MSFT', 'TSLA', 'NVDA', 'AMZN'], etf: ['SPY', 'QQQ', 'IWM'], index: ['SPX', 'NDX', 'VIX'], crypto: ['BTC', 'ETH'], fx: ['EURUSD', 'USDJPY'], futures: ['ES', 'CL', 'GC'] }
39+const ROOTS = [{ root: 'ES', name: 'E-mini S&P 500', exchange: 'CME', tick_size: 0.25, tick_value: 12.5 }, { root: 'CL', name: 'Crude Oil WTI', exchange: 'NYMEX', tick_size: 0.01, tick_value: 10 }, { root: 'GC', name: 'Gold', exchange: 'COMEX', tick_size: 0.1, tick_value: 10 }]
40+const LIMITS = { data: { tiers: {}, principal: { principal: 'ip:x', kind: 'keyless', tier: 'keyless', window_seconds: 3600, max_rows_per_request: 5000, requests: { limit: 30, remaining: 27, reset: Math.floor(Date.now() / 1000) + 1800 }, rows: { limit: 100000, remaining: 99000, reset: Math.floor(Date.now() / 1000) + 1800 } } }, meta: { count: 1 } }
41+
42+const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { ...RATE, 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) })
43+const err = (code, message, status, headers = {}) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code.toLowerCase()}` }, detail: message }, status, headers)
44+
45+/**
46+ * Route handler factory. `state.calls` collects every bars request; `state.total` = history depth (bars available);
47+ * `state.rateLimit` = true → every bars call answers 429 with Retry-After.
48+ */
49+export function chartsHandler(state = {}) {
50+ const series = new Map()
51+ const seriesFor = (ticker, tf) => { const k = `${ticker}|${tf}`; if (!series.has(k)) series.set(k, makeSeries(ticker, tf, state.total ?? 4200, ticker.length * 13)); return series.get(k) }
52+ state.calls = state.calls || []
53+ return (url) => {
54+ const p = url.pathname
55+ if (p === '/v1/limits') return json(LIMITS)
56+ if (p === '/v1/futures/roots') return json({ data: ROOTS, meta: { count: ROOTS.length } })
57+ let m = /^\/v1\/(stock|etf|index|crypto|fx|futures)\/tickers$/.exec(p)
58+ if (m) { const q = (url.searchParams.get('search') || '').toUpperCase(); const list = (TICKERS[m[1]] || []).filter(t => !q || t.includes(q)); return json({ asset: m[1], timeframe: '1day', adjustment: 'none', count: list.length, tickers: list }) }
59+ m = /^\/v1\/bars\/(\w+)\/([A-Z0-9.]+)$/.exec(p)
60+ if (m) {
61+ const [, asset, ticker] = m
62+ const tf = url.searchParams.get('timeframe') || '1day'
63+ state.calls.push({ asset, ticker, tf, search: url.search })
64+ if (state.rateLimit) return err('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded: 30 requests per hour (keyless).', 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' })
65+ if (!(TICKERS[asset] || []).includes(ticker)) return err('TICKER_NOT_FOUND', `Ticker '${ticker}' not found in ${asset}/${tf}`, 404)
66+ let rows = seriesFor(ticker, tf)
67+ const end = url.searchParams.get('end'), start = url.searchParams.get('start')
68+ if (end) { const e = parse(end) + (tf === '1day' ? 0 : 0); rows = rows.filter(r => parse(r.datetime) <= e) }
69+ if (start) rows = rows.filter(r => parse(r.datetime) >= parse(start))
70+ const limit = Math.min(Number(url.searchParams.get('limit') || 5000), 5000)
71+ if (url.searchParams.get('order') === 'desc') rows = rows.slice().reverse()
72+ rows = rows.slice(0, limit)
73+ return json({ count: rows.length, data: rows }, 200, { 'X-Row-Count': String(rows.length) })
74+ }
75+ return null
76+ }
77+}
modified hfmarketdata/web/src/charts/engine/index.js +1 −1
@@ -43,7 +43,7 @@ export function createChart(container, options = {}) {
43 let raf = 0 43 let raf = 0
44 let hover = null 44 let hover = null
45 45
46− container.style.position = container.style.position || 'relative' 46+ if (getComputedStyle(container).position === 'static') container.style.position = 'relative'
47 const main = document.createElement('canvas') 47 const main = document.createElement('canvas')
48 const overlay = document.createElement('canvas') 48 const overlay = document.createElement('canvas')
49 for (const c of [main, overlay]) { c.style.position = 'absolute'; c.style.inset = '0'; c.style.width = '100%'; c.style.height = '100%'; container.appendChild(c) } 49 for (const c of [main, overlay]) { c.style.position = 'absolute'; c.style.inset = '0'; c.style.width = '100%'; c.style.height = '100%'; container.appendChild(c) }
modified hfmarketdata/web/src/pages/charts/Toolbar.jsx +3 −2
@@ -14,8 +14,9 @@ import { AutoIcon, CalendarIcon, CameraIcon, CompareIcon, DotsIcon, ExpandIcon,
14 export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullscreen, table, drawbarOpen, symbolRef, isMobile }) { 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 15 const TypeIcon = SERIES_ICONS[state.type] || SERIES_ICONS.candles
16 const typeLabel = SERIES_TYPES.find(([id]) => id === state.type)?.[1] || '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 17 const hasCompare = state.compares.length > 0
18+ const scale = hasCompare ? 'percent' : state.scale // comparisons force the percent scale (engine contract)
19+ const scaleLabel = scale === 'log' ? 'Log' : scale === 'percent' ? '%' : 'Lin'
19 const shareUrl = () => window.location.href 20 const shareUrl = () => window.location.href
20 return ( 21 return (
21 <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar"> 22 <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar">
@@ -46,7 +47,7 @@ export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullsc
46 )} 47 )}
47 <span className="ch-tb-spacer" /> 48 <span className="ch-tb-spacer" />
48 <Menu label="Price scale" value={scaleLabel} icon={<ScaleIcon />} align="end" testId="ch-scale-menu"> 49 <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+ <ScalePanel scale={scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={hasCompare} />
50 </Menu> 51 </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.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 <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>
modified hfmarketdata/web/src/pages/charts/charts.css +3 −1
@@ -26,7 +26,7 @@
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; } 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); } 27 .ch-symbol:focus-within { border-color: var(--accent); }
28 .ch-symbol-icon { width: 16px; height: 16px; color: var(--fg-3); flex: none; } 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; } 29+.ch-symbol-input { flex: 1; min-width: 0; height: var(--tap); min-height: var(--tap); 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; } 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; } 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; } 32 .ch-symbol:focus-within .ch-symbol-current { display: none; }
@@ -161,6 +161,8 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
161 } 161 }
162 @media (max-width: 960px) { 162 @media (max-width: 960px) {
163 .ch-toolbar { height: 52px; gap: 2px; padding: 0 6px; } 163 .ch-toolbar { height: 52px; gap: 2px; padding: 0 6px; }
164+ .ch-seg { padding: 0; }
165+ .ch-seg-btn { height: var(--tap); min-width: 38px; }
164 .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; } 166 .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; }
165 .ch-body { position: relative; } 167 .ch-body { position: relative; }
166 .ch-tool { width: 40px; height: 40px; } 168 .ch-tool { width: 40px; height: 40px; }
167 169
Menu

Theme