SPB Git forge

spb/hfmarketdata

Public

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

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

web: charts — UI d'analyse technique : bibliothèque d'indicateurs (⌘I, catégories, favoris, liste active), dialogue de réglages (Inputs/Style/Visibility, aperçu live, reset), barre de dessin à flyouts + propriétés flottantes + édition de texte, bar replay, layouts 1/2/4 avec sync, templates, menu contextuel, panneau de raccourcis, légende par plot + aria-live describeVisible, mobile en bottom sheets ; e2e étendus (17 verts, 3 fixme moteur v2)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent a7392c3

19 changed files +2,229 −581

modified hfmarketdata/web/e2e/charts.spec.js +331 −49
@@ -1,36 +1,44 @@
1 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.
2 +// window.__hfmdChart (and window.__hfmdCharts[i] per layout slot) only with ?debug=1 (or in dev).
3 +// Tests marked `fixme` need the engine v2 additions (drawingSelect / textEdit events, listIndicators, setReplay…):
4 +// they are reactivated at merge time.
3 5 import { expect, test } from '@playwright/test'
4 6 import { mockAnon } from './mocks.js'
5 7 import { chartsHandler } from './fixtures/charts.js'
6 8
7 9 async function setup(page, state = {}) {
8 10 const handler = chartsHandler(state)
9 − await mockAnon(page, url => handler(url))
11 + await mockAnon(page, (url, route, req) => handler(url, route, req))
10 12 return state
11 13 }
12 −const legend = page => page.getByTestId('ch-legend')
14 +const legend = page => page.getByTestId('ch-legend').first()
13 15 const barsCalls = (state, pred = () => true) => state.calls.filter(pred)
16 +const NO_LIMIT_TEXT = /req left|requests? per (hour|minute)|quota|API key|Sign in for|Create free account|rate limit/i
14 17
15 18 test.describe('charts', () => {
16 − test('loads AAPL 1D by default: one bars request, legend, status, URL', async ({ page }) => {
19 + test('loads AAPL 1D by default: one generous site request, whole history, legend, status, URL — and no quota UI at all', async ({ page }) => {
17 20 const state = await setup(page)
18 21 await page.goto('/charts')
19 22 await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL')
20 23 await expect(page.getByTestId('ch-legend-ohlc')).toContainText('O')
21 − await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/)
24 + await expect(page.getByTestId('ch-status-count')).toContainText(/4,200 bars/)
25 + await expect(page.getByTestId('ch-status-start')).toHaveText('start of history')
22 26 await expect(page).toHaveURL(/\/charts\?s=AAPL$/)
23 27 expect(barsCalls(state)).toHaveLength(1)
24 − expect(state.calls[0]).toMatchObject({ asset: 'stock', ticker: 'AAPL', tf: '1day' })
28 + expect(state.calls[0]).toMatchObject({ asset: 'stock', ticker: 'AAPL', tf: '1day', client: 'charts' })
25 29 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()
30 + expect(state.calls[0].search).toContain('limit=10000')
31 + // nothing on the page talks about limits, quotas, keys or accounts
32 + await expect(page.getByTestId('ch-status-quota')).toHaveCount(0)
33 + expect(await page.getByTestId('ch-page').innerText()).not.toMatch(NO_LIMIT_TEXT)
34 + await page.getByTestId('ch-settings-menu').click()
35 + expect(await page.getByRole('menu', { name: 'Settings' }).innerText()).not.toMatch(NO_LIMIT_TEXT)
36 + await page.keyboard.press('Escape')
29 37 await expect(page).toHaveTitle(/AAPL 1D · Charts/)
30 38 await page.screenshot({ path: '/tmp/hfmd-shots/charts-desktop.png' })
31 39 })
32 40
33 − test('timeframe switch keeps the previous render (no skeleton), updates URL, legend and request', async ({ page }) => {
41 + test('timeframe switch keeps the previous render (no skeleton), updates URL, legend and request (20 000 minute bars)', async ({ page }) => {
34 42 const state = await setup(page)
35 43 await page.goto('/charts')
36 44 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
@@ -40,7 +48,8 @@ test.describe('charts', () => {
40 48 await expect(page).toHaveURL(/tf=1min/)
41 49 await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1m')
42 50 await expect.poll(() => barsCalls(state, c => c.tf === '1min').length).toBe(1)
43 − expect(state.calls[1].search).toContain('limit=3000')
51 + expect(state.calls[1].search).toContain('limit=20000')
52 + expect(state.calls[1].client).toBe('charts')
44 53 await expect(page.getByTestId('ch-legend-ohlc')).toContainText('ET')
45 54 })
46 55
@@ -58,7 +67,7 @@ test.describe('charts', () => {
58 67 await expect(page).toHaveTitle(/MSFT 1h · Charts/)
59 68 })
60 69
61 − test('indicators: add from the searchable menu, edit a parameter inline, remove', async ({ page }) => {
70 + test('indicators: quick menu add, settings dialog (inputs / style / visibility, live, reset), hide, remove', async ({ page }) => {
62 71 await setup(page)
63 72 await page.goto('/charts?debug=1')
64 73 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
@@ -70,18 +79,79 @@ test.describe('charts', () => {
70 79 await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
71 80 await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 20')
72 81 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')
82 + // ⚙ → settings dialog, Inputs tab generated from the catalog
83 + await page.getByTestId('ch-legend-gear').click()
84 + const dlg = page.getByTestId('ch-ind-settings')
85 + await expect(dlg).toBeVisible()
86 + await expect(dlg.getByTestId('ch-ind-inputs')).toBeVisible()
87 + await dlg.getByTestId('ch-ind-inputs').getByRole('spinbutton').first().fill('50')
76 88 await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 50')
77 89 await expect(page).toHaveURL(/ind=ema:50/)
78 90 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()
91 + // Style tab: one fieldset per plot with colour / width / style
92 + await dlg.getByTestId('ch-ind-tab-style').click()
93 + await expect(dlg.getByTestId('ch-ind-style').locator('fieldset')).toHaveCount(1)
94 + await expect(dlg.getByTestId('ch-ind-style').getByLabel('Width')).toBeVisible()
95 + // Visibility tab: hide on 1D → removed from the engine, still in the legend
96 + await dlg.getByTestId('ch-ind-tab-visibility').click()
97 + await dlg.getByTestId('ch-ind-vis-1day').uncheck()
98 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().length)).toBe(0)
99 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
100 + await dlg.getByTestId('ch-ind-reset').click()
101 + await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 20')
102 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().length)).toBe(1)
103 + await dlg.getByTestId('ch-ind-done').click()
104 + await expect(dlg).toHaveCount(0)
105 + // hide / show from the legend, then remove
106 + await page.getByRole('button', { name: 'Hide EMA 20' }).click()
107 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().length)).toBe(0)
108 + await page.getByRole('button', { name: 'Show EMA 20' }).click()
109 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().length)).toBe(1)
110 + await page.getByRole('button', { name: 'Remove EMA 20' }).click()
81 111 await expect(page.getByTestId('ch-legend-ind')).toHaveCount(0)
82 112 await expect(page).not.toHaveURL(/ind=/)
83 113 })
84 114
115 + test('indicator library (⌘I): search, categories, favourites, one-click add, active list reorder / hide / remove', async ({ page }) => {
116 + await setup(page)
117 + await page.goto('/charts?debug=1')
118 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
119 + await page.keyboard.press('ControlOrMeta+i')
120 + const lib = page.getByTestId('ch-library')
121 + await expect(lib).toBeVisible()
122 + await expect(lib.getByTestId('ch-lib-cat-momentum')).toBeVisible()
123 + await lib.getByTestId('ch-lib-cat-bands').click()
124 + await expect(lib.getByTestId('ch-lib-add-bollinger')).toBeVisible()
125 + await expect(lib.getByTestId('ch-lib-add-rsi')).toHaveCount(0)
126 + await lib.getByTestId('ch-lib-search').fill('relative')
127 + await expect(lib.getByTestId('ch-lib-add-rsi')).toBeVisible()
128 + await expect(lib.getByTestId('ch-lib-list')).toContainText(/oversold/)
129 + await lib.getByTestId('ch-lib-fav-rsi').click()
130 + await lib.getByTestId('ch-lib-add-rsi').click()
131 + await lib.getByTestId('ch-lib-search').fill('')
132 + await lib.getByTestId('ch-lib-cat-favorites').click()
133 + await expect(lib.getByTestId('ch-lib-add-rsi')).toBeVisible()
134 + await lib.getByTestId('ch-lib-cat-trend').click()
135 + await lib.getByTestId('ch-lib-add-sma').click()
136 + await expect(lib.getByTestId('ch-lib-active-row')).toHaveCount(2)
137 + await expect(lib.getByTestId('ch-lib-active-row').nth(0)).toContainText('RSI 14')
138 + await lib.getByRole('button', { name: 'Move SMA 20 up' }).click()
139 + await expect(lib.getByTestId('ch-lib-active-row').nth(0)).toContainText('SMA 20')
140 + await lib.getByRole('button', { name: 'Hide RSI 14' }).click()
141 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => i.type))).toEqual(['sma'])
142 + await lib.getByRole('button', { name: 'Remove RSI 14' }).click()
143 + await expect(lib.getByTestId('ch-lib-active-row')).toHaveCount(1)
144 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-library.png' })
145 + await page.keyboard.press('Escape')
146 + await expect(lib).toHaveCount(0)
147 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
148 + // favourites persist
149 + await page.reload()
150 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
151 + await page.keyboard.press('ControlOrMeta+i')
152 + await expect(page.getByTestId('ch-library').getByTestId('ch-lib-cat-favorites')).toContainText('(1)')
153 + })
154 +
85 155 test('compare: adds a % overlay, chip + legend row, one request, URL cmp=', async ({ page }) => {
86 156 const state = await setup(page)
87 157 await page.goto('/charts')
@@ -100,32 +170,50 @@ test.describe('charts', () => {
100 170 await expect(page.getByTestId('ch-legend-cmp')).toHaveCount(0)
101 171 })
102 172
103 − test('infinite history: needMoreLeft loads older bars once, then "start of history"', async ({ page }) => {
104 − const state = await setup(page, { total: 2000 })
173 + test('fluid infinite history: prefetch below 1 000 bars left, chained pages, merged + sorted, "start of history"', async ({ page }) => {
174 + const state = await setup(page, { total: 25_000 })
105 175 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/)
176 + await expect(page.getByTestId('ch-status-count')).toContainText(/10,000 bars/)
177 + expect(barsCalls(state)).toHaveLength(1)
178 + // the viewport is far from the left edge: nothing more is requested
179 + await page.waitForTimeout(300)
180 + expect(barsCalls(state)).toHaveLength(1)
181 + // pan close to the left edge (< 1 000 bars remain): a page of 10 000 is fetched, then another since we are still near it
182 + await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 500, toIndex: 620 }))
183 + await expect(page.getByTestId('ch-status-count')).toContainText(/25,000 bars/)
109 184 await expect(page.getByTestId('ch-status-start')).toHaveText('start of history')
110 − expect(barsCalls(state)).toHaveLength(2)
185 + expect(barsCalls(state)).toHaveLength(3)
111 186 expect(state.calls[1].search).toMatch(/end=\d{4}-\d{2}-\d{2}/)
187 + expect(state.calls[1].search).toContain('limit=10000')
112 188 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 })
189 + expect(data).toEqual({ n: 25_000, sorted: true })
114 190 // no further request once the start is reached
115 191 await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 80 }))
116 192 await page.waitForTimeout(300)
117 − expect(barsCalls(state)).toHaveLength(2)
193 + expect(barsCalls(state)).toHaveLength(3)
194 + // comparisons are paginated too
195 + await page.getByTestId('ch-cmp-menu').click()
196 + await page.locator('#ch-compare-input').fill('MSF')
197 + await page.getByTestId('ch-symbol-list').getByRole('option', { name: /MSFT/ }).click()
198 + await page.keyboard.press('Escape')
199 + await expect.poll(() => barsCalls(state, c => c.ticker === 'MSFT').length, { timeout: 10_000 }).toBe(3)
200 + await expect(page.getByTestId('ch-legend-cmp')).not.toContainText('loading')
118 201 })
119 202
120 − test('429 shows "rate limited — not down" with countdown and free-account CTA', async ({ page }) => {
121 − await setup(page, { rateLimit: true })
203 + test('a 429 / 5xx is a generic transient error with Retry — no countdown, no account CTA, no quota wording', async ({ page }) => {
204 + const state = await setup(page, { rateLimit: true })
122 205 await page.goto('/charts')
123 − const err = page.getByTestId('ch-error').locator('.error-state.is-429')
206 + const err = page.getByTestId('ch-error').locator('.error-state')
124 207 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()
208 + await expect(err).toContainText('Temporarily unavailable')
209 + await expect(err.locator('.countdown')).toHaveCount(0)
210 + await expect(err.getByRole('link', { name: /free account/i })).toHaveCount(0)
211 + expect(await page.getByTestId('ch-page').innerText()).not.toMatch(NO_LIMIT_TEXT)
128 212 await page.screenshot({ path: '/tmp/hfmd-shots/charts-429.png' })
213 + state.rateLimit = false
214 + await err.getByRole('button', { name: 'Retry' }).click()
215 + await expect(page.getByTestId('ch-status-count')).toContainText(/4,200 bars/)
216 + await expect(page.getByTestId('ch-error')).toHaveCount(0)
129 217 })
130 218
131 219 test('unknown symbol: 404 state with suggestions, picking one recovers', async ({ page }) => {
@@ -139,8 +227,8 @@ test.describe('charts', () => {
139 227 await expect(page.getByTestId('ch-error')).toHaveCount(0)
140 228 })
141 229
142 − test('symbol search: groups by asset, keyboard pick, recents; `/` focuses it', async ({ page }) => {
143 − await setup(page)
230 + test('symbol search: groups by asset, keyboard pick, recents; `/` focuses it; list calls are site calls', async ({ page }) => {
231 + const state = await setup(page)
144 232 await page.goto('/charts')
145 233 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
146 234 await page.keyboard.press('/')
@@ -156,34 +244,209 @@ test.describe('charts', () => {
156 244 await expect(page.getByTestId('ch-adj-menu')).toBeVisible()
157 245 await page.getByTestId('ch-symbol-input').focus()
158 246 await expect(list).toContainText('Recent')
247 + expect(state.listCalls.length).toBeGreaterThan(0)
248 + expect(state.listCalls.every(c => c.client === 'charts')).toBe(true)
159 249 })
160 250
161 − test('drawing tools: keyboard shortcuts toggle tools, table view lists visible bars with CSV export', async ({ page }) => {
251 + test('drawing bar: groups with flyouts, last tool memory, shortcuts, magnet / stay-in-mode / hide / lock toggles', async ({ page }) => {
162 252 await setup(page)
163 − await page.goto('/charts')
253 + await page.goto('/charts?debug=1')
164 254 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
255 + const bar = page.getByTestId('ch-drawbar')
165 256 await page.keyboard.press('t')
166 − await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'true')
257 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('aria-pressed', 'true')
258 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('data-tool', 'trendline')
167 259 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)
260 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('aria-pressed', 'false')
261 + // flyout of the Lines group: pick the ray → the group button now shows / activates the ray
262 + await bar.getByTestId('ch-group-more-lines').click()
263 + const fly = page.getByTestId('ch-flyout-lines')
264 + await expect(fly).toBeVisible()
265 + await expect(fly.locator('[data-tool]')).toHaveCount(6) // engine v1: trendline, ray, extended, hline, vline, channel
266 + await fly.locator('[data-tool="ray"]').click()
267 + await expect(fly).toHaveCount(0)
268 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('data-tool', 'ray')
269 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('aria-pressed', 'true')
270 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.drawings.tool)).toBe('ray')
271 + await bar.getByTestId('ch-group-lines').click() // click again = back to the cursor
272 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('aria-pressed', 'false')
273 + await bar.getByTestId('ch-group-lines').click() // and the group remembers the ray
274 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.drawings.tool)).toBe('ray')
275 + await page.keyboard.press('f')
276 + await expect(bar.getByTestId('ch-group-fib')).toHaveAttribute('aria-pressed', 'true')
277 + await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('aria-pressed', 'false')
278 + // toggles
279 + await bar.getByTestId('ch-draw-magnet').click()
280 + await expect(bar.getByTestId('ch-draw-magnet')).toHaveAttribute('aria-pressed', 'true')
281 + await expect(page.getByTestId('ch-magnet')).toHaveAttribute('aria-pressed', 'true')
282 + await bar.getByTestId('ch-draw-lock').click()
283 + await expect(bar.getByTestId('ch-draw-lock')).toHaveAttribute('aria-pressed', 'true')
284 + await expect(bar.getByTestId('ch-draw-clear')).toBeDisabled()
285 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-drawbar.png' })
286 + })
287 +
288 + test('drawing properties bar appears on selection (colour, width, style, text, lock, duplicate, delete)', async ({ page }) => {
289 + test.fixme(true, 'moteur v2 requis : événement drawingSelect + getDrawingStyle/setDrawingStyle')
290 + await setup(page)
291 + await page.goto('/charts?debug=1')
292 + await expect(page.getByTestId('ch-props')).toBeVisible()
293 + })
294 +
295 + test('inline text edit on the textEdit event', async ({ page }) => {
296 + test.fixme(true, 'moteur v2 requis : événement textEdit + setDrawingText')
297 + await setup(page)
298 + await page.goto('/charts?debug=1')
299 + await expect(page.getByTestId('ch-textedit')).toBeVisible()
179 300 })
180 301
181 − test('share copies the current URL; screenshot produces a PNG', async ({ page }) => {
302 + test('patterns / Fibonacci-Gann / positions flyouts list the v2 tools', async ({ page }) => {
303 + test.fixme(true, 'moteur v2 requis : DRAWING_TOOLS étendu (elliott-impulse, xabcd, long-position, gann-fan…)')
304 + await setup(page)
305 + await page.goto('/charts')
306 + await expect(page.getByTestId('ch-group-patterns')).toBeVisible()
307 + })
308 +
309 + test('bar replay: control bar, step / play / pause / speed / seek, indicators recomputed, exit restores the series', async ({ page }) => {
310 + await setup(page)
311 + await page.goto('/charts?ind=sma:20&debug=1')
312 + await expect(page.getByTestId('ch-status-count')).toContainText(/4,200 bars/)
313 + await page.getByTestId('ch-replay-btn').click()
314 + const bar = page.getByTestId('ch-replay')
315 + await expect(bar).toBeVisible()
316 + await expect(page.getByTestId('ch-legend-replay')).toContainText('Replay')
317 + const shown = await page.evaluate(() => window.__hfmdChart.getData().length)
318 + expect(shown).toBeLessThan(4200)
319 + await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown}/4200`)
320 + const sma = await page.evaluate(() => { const v = window.__hfmdChart.getIndicators()[0].values.sma; return v[v.length - 1] })
321 + await bar.getByTestId('ch-replay-fwd').click()
322 + await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown + 1}/4200`)
323 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(shown + 1)
324 + await expect.poll(() => page.evaluate(() => { const v = window.__hfmdChart.getIndicators()[0].values.sma; return v[v.length - 1] })).not.toBe(sma) // indicators follow the replay
325 + await bar.getByTestId('ch-replay-speed').selectOption('10')
326 + await bar.getByTestId('ch-replay-play').click()
327 + await expect(bar.getByTestId('ch-replay-play')).toHaveAttribute('aria-pressed', 'true')
328 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length), { timeout: 5000 }).toBeGreaterThan(shown + 3)
329 + await bar.getByTestId('ch-replay-play').click()
330 + await expect(bar.getByTestId('ch-replay-play')).toHaveAttribute('aria-pressed', 'false')
331 + await bar.getByTestId('ch-replay-slider').fill('100')
332 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(101)
333 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-replay.png' })
334 + await bar.getByTestId('ch-replay-exit').click()
335 + await expect(bar).toHaveCount(0)
336 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(4200)
337 + })
338 +
339 + test('layouts: 2 side by side, per-chart symbol, active chart drives the toolbar, URL layout= / s2=, Alt+1 back', async ({ page }) => {
340 + const state = await setup(page)
341 + await page.goto('/charts?debug=1')
342 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
343 + await page.getByTestId('ch-layout-menu').click()
344 + await page.getByTestId('ch-layout-2h').click()
345 + await expect(page.getByTestId('ch-view-1')).toBeVisible()
346 + await expect(page.getByTestId('ch-page')).toHaveAttribute('data-layout', '2h')
347 + await expect(page).toHaveURL(/layout=2h/)
348 + await expect(page).toHaveURL(/s2=AAPL/)
349 + // second chart: activate, change its symbol
350 + await page.getByTestId('ch-view-1').click({ position: { x: 200, y: 200 } })
351 + await expect(page.getByTestId('ch-view-1')).toHaveClass(/is-active/)
352 + await expect(page.getByTestId('ch-status-layout')).toContainText('chart 2 active')
353 + await page.keyboard.press('/')
354 + await page.keyboard.type('MSFT')
355 + await page.keyboard.press('Enter')
356 + await expect(page.getByTestId('ch-view-1').locator('.ch-legend-sym')).toHaveText('MSFT')
357 + await expect(page.getByTestId('ch-view-0').locator('.ch-legend-sym')).toHaveText('AAPL')
358 + await expect(page).toHaveURL(/s=AAPL.*s2=MSFT/)
359 + await expect.poll(() => barsCalls(state, c => c.ticker === 'MSFT').length).toBe(1)
360 + await page.getByTestId('ch-tf-1hour').click()
361 + await expect(page).toHaveURL(/tf2=1hour/)
362 + await expect(page.getByTestId('ch-view-0').locator('.ch-legend-tf')).toHaveText('1D')
363 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-layout-2h.png' })
364 + // reload restores both charts
365 + await page.reload()
366 + await expect(page.getByTestId('ch-view-1').locator('.ch-legend-sym')).toHaveText('MSFT')
367 + await expect(page.getByTestId('ch-view-1').locator('.ch-legend-tf')).toHaveText('1h')
368 + // Alt+1 → single
369 + await page.getByTestId('ch-view-0').click({ position: { x: 200, y: 200 } })
370 + await page.keyboard.press('Alt+1')
371 + await expect(page.getByTestId('ch-view-1')).toHaveCount(0)
372 + await expect(page).not.toHaveURL(/layout=/)
373 + })
374 +
375 + test('templates: save the current set, apply it to a fresh chart, default template, delete', async ({ page }) => {
376 + await setup(page)
377 + await page.goto('/charts?ind=ema:50,rsi:14&type=hollow')
378 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2)
379 + await page.getByTestId('ch-tpl-menu').click()
380 + await page.getByTestId('ch-tpl-name').fill('My setup')
381 + await page.getByTestId('ch-tpl-save').click()
382 + await expect(page.getByTestId('ch-tpl-row')).toHaveCount(1)
383 + await expect(page.getByTestId('ch-tpl-row')).toContainText('2 indicators · hollow')
384 + await page.getByRole('button', { name: 'Use My setup as default template' }).click()
385 + await page.keyboard.press('Escape')
386 + // a fresh chart (no ind/type in the URL) opens with the default template
387 + await page.goto('/charts?s=MSFT')
388 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2)
389 + await expect(page.getByTestId('ch-legend-ind').nth(0)).toContainText('EMA 50')
390 + await expect(page.getByTestId('ch-type-menu')).toContainText('Hollow')
391 + // explicit URL wins over the default template; applying the template replaces the set
392 + await page.goto('/charts?s=MSFT&ind=sma:10')
393 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
394 + await page.getByTestId('ch-tpl-menu').click()
395 + await page.getByTestId('ch-tpl-apply-My-setup').click()
396 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2)
397 + await expect(page).toHaveURL(/ind=ema:50,rsi:14/)
398 + await page.getByTestId('ch-tpl-menu').click()
399 + await page.getByRole('button', { name: 'Delete template My setup' }).click()
400 + await expect(page.getByTestId('ch-tpl-row')).toHaveCount(0)
401 + })
402 +
403 + test('context menu: reset view, horizontal line at the price, alert line, copy price; shortcuts panel (?)', async ({ page }) => {
404 + await setup(page)
405 + await page.goto('/charts?debug=1')
406 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
407 + const canvas = page.getByTestId('ch-canvas')
408 + const box = await canvas.boundingBox()
409 + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
410 + await page.mouse.move(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5)
411 + await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' })
412 + const menu = page.getByTestId('ch-ctx')
413 + await expect(menu).toBeVisible()
414 + await expect(menu.getByTestId('ch-ctx-hline')).toContainText(/Horizontal line at \d/)
415 + await menu.getByTestId('ch-ctx-hline').click()
416 + await expect(menu).toHaveCount(0)
417 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().length)).toBe(1)
418 + await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' })
419 + await page.getByTestId('ch-ctx-alert').click()
420 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().map(d => d.type))).toEqual(['hline', 'hline'])
421 + expect(await page.evaluate(() => window.__hfmdChart.getDrawings()[1].text)).toMatch(/^Alert /)
422 + await expect(page.getByTestId('ch-draw-clear')).toBeEnabled()
423 + await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' })
424 + await expect(page.getByTestId('ch-ctx-reset')).toBeVisible()
425 + await page.keyboard.press('Escape')
426 + await expect(menu).toHaveCount(0)
427 + await page.keyboard.press('?')
428 + const keys = page.getByTestId('ch-shortcuts')
429 + await expect(keys).toBeVisible()
430 + await expect(keys).toContainText('Indicator library')
431 + await expect(keys).toContainText('Fibonacci retracement')
432 + await page.keyboard.press('Escape')
433 + await expect(keys).toHaveCount(0)
434 + })
435 +
436 + test('table view lists visible bars with CSV export; share copies the URL; aria-live describes the visible range', async ({ page }) => {
182 437 await setup(page)
183 438 await page.goto('/charts?s=MSFT&tf=1hour')
184 439 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
440 + await expect(page.getByTestId('ch-legend-describe')).toContainText(/MSFT 1h: \d+ bars from/)
185 441 const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 })
186 442 expect(blobSize).toBe(-1) // not exposed without ?debug=1
443 + await page.getByTestId('ch-table-btn').click()
444 + const table = page.getByTestId('ch-table')
445 + await expect(table).toBeVisible()
446 + await expect(table.locator('tbody tr').first()).toContainText('2024-06-28')
447 + await expect(table.getByTestId('ch-table-csv')).toBeEnabled()
448 + await page.getByRole('button', { name: 'Close table' }).click()
449 + await expect(table).toHaveCount(0)
187 450 await page.getByTestId('ch-share').click()
188 451 await expect(page.getByTestId('ch-share')).toHaveAttribute('aria-label', 'Link copied')
189 452 })
@@ -192,7 +455,7 @@ test.describe('charts', () => {
192 455 test.describe('charts mobile', () => {
193 456 test.use({ viewport: { width: 393, height: 851 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 })
194 457
195 − test('condensed toolbar, bottom sheet with the rest, no horizontal overflow, 44 px targets', async ({ page }) => {
458 + test('condensed toolbar, bottom sheet (library, layout, templates, replay), drawbar flyouts as sheets, 44 px targets', async ({ page }) => {
196 459 await setup(page)
197 460 await page.goto('/charts')
198 461 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
@@ -202,14 +465,33 @@ test.describe('charts mobile', () => {
202 465 expect(small).toBe(0)
203 466 const pageBox = await page.getByTestId('ch-page').boundingBox()
204 467 expect(pageBox.height).toBeLessThanOrEqual(851 - 56 + 1)
468 + expect(await page.getByTestId('ch-page').innerText()).not.toMatch(NO_LIMIT_TEXT)
205 469 await page.getByTestId('ch-more').click()
206 470 const sheet = page.getByTestId('ch-sheet')
207 471 await expect(sheet).toBeVisible()
472 + await expect(sheet.getByTestId('ch-sheet-library')).toBeVisible()
473 + await expect(sheet.getByTestId('ch-sheet-layout')).toBeVisible()
474 + await expect(sheet.getByTestId('ch-sheet-templates')).toBeVisible()
475 + await expect(sheet.getByTestId('ch-sheet-replay')).toBeVisible()
208 476 await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile-sheet.png' })
209 477 await sheet.getByTestId('ch-sheet-indicators').click()
210 478 await page.getByTestId('ch-ind-rsi').click()
211 479 await expect(page.getByTestId('ch-legend-ind')).toContainText('RSI 14')
212 480 await expect(page.getByTestId('ch-sheet')).toHaveCount(0)
481 + // library as a bottom sheet
482 + await page.getByTestId('ch-more').click()
483 + await page.getByTestId('ch-sheet-library').click()
484 + await expect(page.getByTestId('ch-library')).toHaveClass(/ch-sheet/)
485 + await page.getByTestId('ch-library').getByRole('button', { name: 'Close' }).click()
486 + // drawing bar → horizontal, flyout opens as a sheet
487 + await page.getByRole('button', { name: 'Drawing tools' }).click()
488 + const bar = page.getByTestId('ch-drawbar')
489 + await expect(bar).toHaveClass(/is-horizontal/)
490 + await bar.getByTestId('ch-group-more-shapes').click()
491 + const fly = page.getByTestId('ch-flyout-shapes')
492 + await expect(fly).toHaveClass(/ch-sheet/)
493 + await fly.locator('[data-tool="rect"]').click()
494 + await expect(bar.getByTestId('ch-group-shapes')).toHaveAttribute('aria-pressed', 'true')
213 495 await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile.png' })
214 496 })
215 497 })
added hfmarketdata/web/src/pages/charts/ChartView.jsx +518 −0
@@ -0,0 +1,518 @@
1 +// One chart of the /charts layout: canvas + legend + overlays, data loading (generous initial window, fluid backward
2 +// pagination of the main series AND of the comparisons), indicators (hidden / per-timeframe visibility / per-plot
3 +// styles), drawings (persistence, hide-all, lock-all, selection → properties bar, inline text edit), bar replay
4 +// (engine v2 `setReplay` or a page-side emulation), crosshair / time synchronisation with sibling charts.
5 +//
6 +// Engine v2 additions are used behind `typeof chart.x === 'function'` guards so the page works with the v1 engine.
7 +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
8 +import { createChart } from '../../charts/engine/index.js'
9 +import { CONTRACT_ASSET, INITIAL_BARS, PREFETCH_BARS, TF_LABEL, TF_MS, barsErrorMessage, barsToCsv, getCached, loadBars, loadOlderBars, mergeBars, putCached } from '../../charts/data/bars.js'
10 +import { assetLabel, defaultAdjustment, loadContracts, loadRootSpec, parseContract, searchSymbols } from '../../charts/data/symbols.js'
11 +import { formatPrice, formatStampLabel, priceFormatFor, timezoneLabel } from '../../charts/data/session.js'
12 +import { readDrawings, writeDrawings } from '../../charts/data/state.js'
13 +import { indicatorDef, resolveIndicator, setEngineCatalog } from './indicators.js'
14 +import { buildTheme, seriesColor } from './theme.js'
15 +import { ErrorState, Skeleton } from '../../components/States.jsx'
16 +import Button from '../../components/Button.jsx'
17 +import Legend from './Legend.jsx'
18 +
19 +const has = (chart, m) => !!chart && typeof chart[m] === 'function'
20 +const isV2 = chart => has(chart, 'listIndicators')
21 +
22 +/** Fallback description of the visible range (engine v2 has `describeVisible()`). */
23 +export function describeVisibleFallback(chart, cs, decimals, tz) {
24 + const data = chart.getData()
25 + if (!data.length) return ''
26 + const r = chart.getVisibleRange()
27 + const a = Math.max(0, r.fromIndex), b = Math.min(data.length - 1, r.toIndex)
28 + if (!(b >= a)) return ''
29 + let hi = -Infinity, lo = Infinity
30 + for (let i = a; i <= b; i++) { if (data[i].h > hi) hi = data[i].h; if (data[i].l < lo) lo = data[i].l }
31 + const first = data[a], last = data[b]
32 + const chg = first.o ? ((last.c - first.o) / first.o) * 100 : 0
33 + const tfz = cs.tf === '1day' ? '' : tz
34 + return `${cs.label} ${TF_LABEL[cs.tf]}: ${b - a + 1} bars from ${formatStampLabel(first.t, cs.tf, tfz)} to ${formatStampLabel(last.t, cs.tf, tfz)}, open ${formatPrice(first.o, decimals)}, close ${formatPrice(last.c, decimals)}, ${chg >= 0 ? 'up' : 'down'} ${Math.abs(chg).toFixed(2)} percent, high ${formatPrice(hi, decimals)}, low ${formatPrice(lo, decimals)}.`
35 +}
36 +
37 +const ChartView = forwardRef(function ChartView({ cs, onChange, prefs, theme, active, onActivate, index, isMobile, tool, onToolChange, onStatus, onSelection, onContextMenu, onCrosshair, onRange, onReplayJump, replay, hiddenAll, lockedAll, drawLock, onFirstCatalog, debug, reloadKey = 0 }, ref) {
38 + const [status, setStatus] = useState({ phase: 'loading', count: 0, firstT: null, lastT: null, loadingOlder: false, startOfHistory: false, error: null, olderError: null })
39 + const [hover, setHover] = useState(null)
40 + const [lastBars, setLastBars] = useState({ last: null, prev: null })
41 + const [indValues, setIndValues] = useState({})
42 + const [plotColors, setPlotColors] = useState({})
43 + const [name, setName] = useState('')
44 + const [suggestions, setSuggestions] = useState([])
45 + const [decimals, setDecimals] = useState(2)
46 + const [describe, setDescribe] = useState('')
47 + const [textEdit, setTextEdit] = useState(null) // { id, text, x, y }
48 + const [range, setRange] = useState(null)
49 +
50 + const containerRef = useRef(null)
51 + const stageRef = useRef(null)
52 + const chartRef = useRef(null)
53 + const csRef = useRef(cs); csRef.current = cs
54 + const prefsRef = useRef(prefs); prefsRef.current = prefs
55 + const themeRef = useRef(theme); themeRef.current = theme
56 + const abortRef = useRef(null)
57 + const olderRef = useRef({ inflight: 0, done: false, paused: false, error: null })
58 + const cmpRef = useRef(new Map()) // id → { key, ctrl, bars, done, inflight }
59 + const appliedInd = useRef(new Map())
60 + const hoverRaf = useRef(0)
61 + const hoverInfo = useRef(null)
62 + const describeTimer = useRef(0)
63 + const persistDrawings = useRef(true)
64 + const stashedDrawings = useRef(null)
65 + const syncingRange = useRef(false)
66 + const replayFull = useRef(null)
67 + const replayRef = useRef(replay); replayRef.current = replay
68 + const toolRef = useRef(tool); toolRef.current = tool
69 + const drawLockRef = useRef(drawLock); drawLockRef.current = drawLock
70 + const contractMetaRef = useRef(null)
71 +
72 + const tz = timezoneLabel(cs.asset)
73 + const query = useMemo(() => ({ asset: cs.asset, ticker: cs.ticker, timeframe: cs.tf, adjustment: cs.adjustment || defaultAdjustment(cs.asset) }), [cs.asset, cs.ticker, cs.tf, cs.adjustment, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
74 + const queryRef = useRef(query); queryRef.current = query
75 + const decimalsRef = useRef(decimals); decimalsRef.current = decimals
76 +
77 + const emitStatus = useCallback(patch => setStatus(st => { const next = typeof patch === 'function' ? patch(st) : { ...st, ...patch }; onStatus?.(index, next); return next }), [index, onStatus])
78 +
79 + // ---- helpers -------------------------------------------------------------------------------------------------------
80 + const refreshLastValues = useCallback(() => {
81 + const chart = chartRef.current
82 + if (!chart) return
83 + const data = chart.getData()
84 + setLastBars({ last: data[data.length - 1] || null, prev: data[data.length - 2] || null })
85 + const vals = {}, colors = {}
86 + for (const ind of chart.getIndicators()) {
87 + vals[ind.id] = {}
88 + colors[ind.id] = ind.plotColors || {}
89 + const keys = ind.plotColors && Object.keys(ind.plotColors).length ? Object.keys(ind.plotColors) : Object.keys(ind.values || {})
90 + for (const k of keys) {
91 + const arr = ind.values?.[k]
92 + const v = arr && typeof arr.length === 'number' && arr.length ? arr[arr.length - 1] : null // typed arrays: NaN = no value
93 + vals[ind.id][k] = v == null || Number.isNaN(v) ? null : v
94 + }
95 + }
96 + setIndValues(vals)
97 + setPlotColors(colors)
98 + }, [])
99 +
100 + const announce = useCallback(() => {
101 + const chart = chartRef.current
102 + if (!chart || prefsRef.current.announce === false) return
103 + clearTimeout(describeTimer.current)
104 + describeTimer.current = setTimeout(() => {
105 + try {
106 + const text = has(chart, 'describeVisible') ? chart.describeVisible() : describeVisibleFallback(chart, csRef.current, decimalsRef.current, timezoneLabel(csRef.current.asset))
107 + if (text) setDescribe(String(text))
108 + } catch { /* ignore */ }
109 + }, 700)
110 + }, [])
111 +
112 + // ---- chart lifecycle (once) --------------------------------------------------------------------------------------
113 + useEffect(() => {
114 + const el = containerRef.current
115 + if (!el) return undefined
116 + const s = csRef.current
117 + const fitBarsFor = width => Math.max(40, Math.min(200, Math.round(width / 9))) // ~9 px per bar: phones ~45 candles, desktops ~150
118 + const p = prefsRef.current
119 + const chart = createChart(el, { fitBars: fitBarsFor(el.clientWidth || window.innerWidth), theme: buildTheme({ colorblind: p.colorblind }), timeframe: s.tf, sessionLabel: timezoneLabel(s.asset), watermark: p.watermark ? `${s.label} · ${TF_LABEL[s.tf]} · HF Market Data` : undefined, reducedMotion: p.reducedMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches })
120 + chartRef.current = chart
121 + if (debug) { window.__hfmdCharts = window.__hfmdCharts || []; window.__hfmdCharts[index] = chart; if (index === 0) window.__hfmdChart = chart }
122 + if (isV2(chart)) { try { setEngineCatalog(chart.listIndicators()); onFirstCatalog?.() } catch { /* ignore */ } }
123 + if (has(chart, 'setGrid')) { try { chart.setGrid(p.grid !== false) } catch { /* ignore */ } }
124 + const offs = [
125 + chart.on('visibleRangeChange', r => {
126 + setRange(r)
127 + if (!syncingRange.current) onRange?.(index, r)
128 + if (r.fromIndex < PREFETCH_BARS || r.needMoreLeft) loadOlderRef.current()
129 + announce()
130 + }),
131 + chart.on('crosshairMove', info => {
132 + hoverInfo.current = info
133 + cancelAnimationFrame(hoverRaf.current)
134 + hoverRaf.current = requestAnimationFrame(() => {
135 + if (!info) { setHover(null); onCrosshair?.(index, null); return }
136 + const data = chart.getData()
137 + setHover({ bar: info.bar, prev: data[info.index - 1] || null, indicators: info.indicators || {}, compares: info.compares || {} })
138 + onCrosshair?.(index, info)
139 + })
140 + }),
141 + chart.on('drawingsChange', list => { if (persistDrawings.current) writeDrawings(csRef.current, list); onChange?.({ _drawings: list.length }) }),
142 + chart.on('toolChange', t => {
143 + // "stay in drawing mode": the engine drops the tool after each drawing → re-arm it
144 + if (t === null && drawLockRef.current && toolRef.current && toolRef.current !== 'brush') { setTimeout(() => { if (drawLockRef.current && toolRef.current) chart.setDrawingTool(toolRef.current) }, 0); return }
145 + onToolChange?.(t)
146 + }),
147 + chart.on('click', info => { if (replayRef.current?.jumping && info && info.index != null) onReplayJump?.(index, info.index) }),
148 + ]
149 + const onSelect = sel => {
150 + const id = sel == null ? null : typeof sel === 'string' ? sel : sel.id
151 + if (!id) { onSelection?.(index, null); return }
152 + const d = chart.getDrawings().find(x => x.id === id)
153 + let style = d?.style || {}
154 + if (has(chart, 'getDrawingStyle')) { try { style = { ...style, ...(chart.getDrawingStyle(id) || {}) } } catch { /* ignore */ } }
155 + onSelection?.(index, d ? { id, type: d.type, style, text: d.text || '', locked: !!d.locked } : { id, type: sel?.type || 'drawing', style, text: sel?.text || '', locked: false })
156 + }
157 + offs.push(chart.on('drawingSelect', onSelect), chart.on('selectionChange', onSelect))
158 + offs.push(chart.on('textEdit', e => { if (e && e.id) setTextEdit({ id: e.id, text: e.text || '', x: e.x ?? 40, y: e.y ?? 40 }) }))
159 + const onTheme = () => chart.setTheme(buildTheme({ colorblind: prefsRef.current.colorblind }))
160 + window.addEventListener('hfmd:theme', onTheme)
161 + const onResize = () => chart.setOptions({ fitBars: fitBarsFor(el.clientWidth || window.innerWidth) })
162 + window.addEventListener('resize', onResize)
163 + const onCtx = e => {
164 + e.preventDefault()
165 + const info = hoverInfo.current
166 + onContextMenu?.({ chartIndex: index, x: e.clientX, y: e.clientY, price: info?.price ?? null, index: info?.index ?? null, t: info?.bar?.t ?? null })
167 + }
168 + el.addEventListener('contextmenu', onCtx)
169 + return () => {
170 + offs.forEach(off => off())
171 + window.removeEventListener('hfmd:theme', onTheme)
172 + window.removeEventListener('resize', onResize)
173 + el.removeEventListener('contextmenu', onCtx)
174 + cancelAnimationFrame(hoverRaf.current)
175 + clearTimeout(describeTimer.current)
176 + abortRef.current?.abort()
177 + for (const e of cmpRef.current.values()) e.ctrl?.abort()
178 + chart.destroy()
179 + chartRef.current = null
180 + if (window.__hfmdCharts) delete window.__hfmdCharts[index]
181 + if (window.__hfmdChart === chart) delete window.__hfmdChart
182 + }
183 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
184 +
185 + // ---- initial load per (asset, ticker, tf, adjustment) -----------------------------------------------------------
186 + useEffect(() => {
187 + const chart = chartRef.current
188 + if (!chart) return undefined
189 + abortRef.current?.abort()
190 + const ctrl = new AbortController()
191 + abortRef.current = ctrl
192 + olderRef.current = { inflight: 0, done: false, paused: false, error: null }
193 + replayFull.current = null
194 + setSuggestions([])
195 + setTextEdit(null)
196 + chart.setTimeframe(query.timeframe)
197 + chart.setOptions({ sessionLabel: timezoneLabel(query.asset), watermark: prefsRef.current.watermark ? `${cs.label} · ${TF_LABEL[query.timeframe]} · HF Market Data` : undefined })
198 + const drawings = readDrawings({ asset: query.asset, ticker: query.ticker, tf: query.timeframe })
199 + persistDrawings.current = false
200 + chart.setDrawings(hiddenAll ? [] : drawings)
201 + stashedDrawings.current = hiddenAll ? drawings : null
202 + persistDrawings.current = !hiddenAll
203 + onChange?.({ _drawings: drawings.length })
204 +
205 + const apply = (bars, extra) => {
206 + chart.setData(bars)
207 + chart.fitContent(false)
208 + const fmt = priceFormatFor(query.asset, bars, extra?.spec)
209 + setDecimals(fmt.decimals)
210 + chart.setOptions({ priceFormat: fmt })
211 + refreshLastValues()
212 + emitStatus(st => ({ ...st, phase: 'ready', error: null, olderError: null, count: bars.length, firstT: bars[0]?.t ?? null, lastT: bars[bars.length - 1]?.t ?? null, startOfHistory: !!extra?.startOfHistory }))
213 + olderRef.current.done = !!extra?.startOfHistory
214 + announce()
215 + // the viewport may already be close to the left edge (short series): prefetch right away
216 + setTimeout(() => { if (!ctrl.signal.aborted && chart.getVisibleRange().fromIndex < PREFETCH_BARS) loadOlderRef.current() }, 0)
217 + }
218 + contractMetaRef.current = null
219 + const metaP = (async () => {
220 + try {
221 + if (query.asset === 'futures') { const spec = await loadRootSpec(query.ticker, { signal: ctrl.signal }); if (spec) setName(spec.name || ''); return spec }
222 + if (query.asset === CONTRACT_ASSET) { const root = parseContract(query.ticker)?.root; if (root) { const list = await loadContracts(root, { signal: ctrl.signal }); const meta = list.find(c => c.ticker === query.ticker) || null; contractMetaRef.current = meta; setName(meta?.name || ''); return meta } }
223 + setName('')
224 + } catch { setName('') }
225 + return null
226 + })()
227 +
228 + const cached = getCached(query)
229 + if (cached?.bars?.length) { metaP.then(spec => { if (!ctrl.signal.aborted) apply(cached.bars, { spec, startOfHistory: cached.startOfHistory }) }); return () => ctrl.abort() }
230 +
231 + emitStatus(st => ({ ...st, phase: 'loading', error: null, loadingOlder: false, startOfHistory: false, olderError: null }))
232 + ;(async () => {
233 + try {
234 + const spec = await metaP
235 + const res = await loadBars({ ...query, limit: INITIAL_BARS[query.timeframe], signal: ctrl.signal, firstDate: contractMetaRef.current?.firstDate })
236 + if (ctrl.signal.aborted) return
237 + const entry = putCached(query, res.bars, { startOfHistory: res.complete })
238 + apply(entry.bars, { spec, startOfHistory: res.complete })
239 + } catch (e) {
240 + if (e?.name === 'AbortError' || ctrl.signal.aborted) return
241 + emitStatus(st => ({ ...st, phase: 'error', error: e }))
242 + if (e.kind === 'not_found') {
243 + searchSymbols(query.ticker.slice(0, Math.min(3, query.ticker.length)), { 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(() => {})
244 + }
245 + }
246 + })()
247 + return () => ctrl.abort()
248 + }, [query]) // eslint-disable-line react-hooks/exhaustive-deps
249 +
250 + // ---- fluid backward pagination (main series, then chained while the viewport stays near the left edge) -----------
251 + const loadOlderRef = useRef(() => {})
252 + loadOlderRef.current = async () => {
253 + const chart = chartRef.current
254 + const q = queryRef.current
255 + const o = olderRef.current
256 + if (!chart || o.inflight > 0 || o.done || o.paused || o.error) return
257 + const data = chart.getData()
258 + if (!data.length) return
259 + o.inflight++
260 + emitStatus({ loadingOlder: true })
261 + const ctrl = abortRef.current
262 + try {
263 + const res = await loadOlderBars(q, data[0].t, { signal: ctrl?.signal, firstDate: contractMetaRef.current?.firstDate })
264 + if (ctrl?.signal.aborted || queryRef.current !== q) return
265 + if (res.bars.length) chart.prependData(res.bars)
266 + if (res.complete) o.done = true
267 + const all = chart.getData()
268 + emitStatus(st => ({ ...st, loadingOlder: false, count: all.length, firstT: all[0]?.t ?? null, startOfHistory: res.complete }))
269 + refreshLastValues()
270 + loadOlderComparesRef.current()
271 + // keep going while the viewport is still close to the left edge (fast pans, huge zoom-outs)
272 + if (!o.done && chart.getVisibleRange().fromIndex < PREFETCH_BARS) setTimeout(() => loadOlderRef.current(), 0)
273 + } catch (e) {
274 + if (e?.name === 'AbortError') return
275 + // transient failure (burst guard, network, 5xx): stop quietly, keep what we have, offer a retry in the status bar
276 + if (e.kind === 'not_found') o.done = true
277 + else o.error = e
278 + emitStatus(st => ({ ...st, loadingOlder: false, olderError: e.kind === 'not_found' ? null : e }))
279 + } finally {
280 + o.inflight = Math.max(0, o.inflight - 1)
281 + }
282 + }
283 + const retryOlder = useCallback(() => { olderRef.current.error = null; emitStatus({ olderError: null }); loadOlderRef.current() }, [emitStatus])
284 +
285 + // comparisons: extend backwards as far as the main series goes (one compare request in flight at a time)
286 + const loadOlderComparesRef = useRef(() => {})
287 + loadOlderComparesRef.current = async () => {
288 + const chart = chartRef.current
289 + if (!chart) return
290 + const main = chart.getData()
291 + if (!main.length) return
292 + for (const [id, e] of cmpRef.current) {
293 + if (e.inflight || e.done || !e.bars?.length) continue
294 + e.inflight = true
295 + try {
296 + // chain pages until the comparison reaches the first bar of the main series (or its own start of history)
297 + while (!e.done && e.bars[0].t > main[0].t && !e.ctrl.signal.aborted) {
298 + const res = await loadOlderBars(e.q, e.bars[0].t, { signal: e.ctrl.signal })
299 + if (e.ctrl.signal.aborted) return
300 + e.bars = mergeBars(e.bars, res.bars)
301 + e.done = res.complete || !res.bars.length
302 + const cmp = csRef.current.compares.find(c => c.id === id)
303 + if (cmp && themeRef.current) chart.addCompare(id, cmp.ticker, e.bars, seriesColor(themeRef.current, cmp.colorIndex ?? 0))
304 + }
305 + } catch (err) {
306 + if (err?.name !== 'AbortError') e.done = true // do not hammer a failing series
307 + } finally { e.inflight = false }
308 + }
309 + }
310 +
311 + // ---- option sync (never re-creates the chart) -------------------------------------------------------------------
312 + const chartCall = (fn, deps) => useEffect(() => { const c = chartRef.current; if (c) fn(c) }, deps) // eslint-disable-line react-hooks/rules-of-hooks
313 + chartCall(c => c.setSeriesType(prefs.colorblind && cs.type === 'candles' ? 'hollow' : cs.type), [cs.type, prefs.colorblind])
314 + chartCall(c => c.setPriceScale({ mode: cs.compares.length ? 'percent' : cs.scale, auto: prefs.autoScale }), [cs.scale, cs.compares.length, prefs.autoScale])
315 + chartCall(c => c.setVolume(cs.volume), [cs.volume])
316 + chartCall(c => { if (theme) c.setTheme(theme) }, [theme])
317 + chartCall(c => c.setCrosshair({ mode: prefs.magnet ? 'magnet' : 'normal', showLabels: true }), [prefs.magnet])
318 + chartCall(c => c.setOptions({ watermark: prefs.watermark ? `${cs.label} · ${TF_LABEL[cs.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion }), [prefs.watermark, prefs.reducedMotion, cs.label, cs.tf])
319 + chartCall(c => { if (has(c, 'setGrid')) { try { c.setGrid(prefs.grid !== false) } catch { /* ignore */ } } }, [prefs.grid])
320 + chartCall(c => { if (tool !== undefined) c.setDrawingTool(tool) }, [tool])
321 +
322 + // indicators reconciliation (hidden / per-timeframe visibility → not on the chart; styles via v2 updateIndicator)
323 + useEffect(() => {
324 + const c = chartRef.current
325 + if (!c || !theme) return
326 + const v2 = isV2(c)
327 + const want = new Map()
328 + for (const raw of cs.indicators) {
329 + if (!raw.id || raw.colorIndex == null || raw.hidden || raw.visibility?.[cs.tf] === false) continue
330 + const ind = resolveIndicator(raw)
331 + if (!ind) continue // unknown to this engine build: kept in state, not drawn
332 + want.set(ind.id, ind)
333 + }
334 + for (const [id] of appliedInd.current) if (!want.has(id)) { c.removeIndicator(id); appliedInd.current.delete(id) }
335 + for (const [id, ind] of want) {
336 + const firstKey = indicatorDef(ind.type)?.plots?.[0]?.key
337 + const baseColor = ind.plots?.[firstKey]?.color || seriesColor(theme, ind.colorIndex ?? 0)
338 + const key = JSON.stringify([ind.params, ind.plots || null, ind.levels || null, baseColor, ind.pane])
339 + const prev = appliedInd.current.get(id)
340 + if (prev === key) continue
341 + const styleArgs = v2 ? { params: ind.params, plots: ind.plots, levels: ind.levels } : null
342 + if (!prev) {
343 + c.addIndicator({ id, type: ind.type, params: ind.params, pane: ind.pane, colors: [baseColor] })
344 + if (v2 && (ind.plots || ind.levels)) { try { c.updateIndicator(id, styleArgs) } catch { /* ignore */ } }
345 + } else if (v2) {
346 + try { c.updateIndicator(id, styleArgs) } catch { c.updateIndicator(id, ind.params) }
347 + } else {
348 + const prevArr = JSON.parse(prev)
349 + if (prevArr[3] !== baseColor || prevArr[4] !== ind.pane) { c.removeIndicator(id); c.addIndicator({ id, type: ind.type, params: ind.params, pane: ind.pane, colors: [baseColor] }) }
350 + else c.updateIndicator(id, ind.params)
351 + }
352 + appliedInd.current.set(id, key)
353 + }
354 + refreshLastValues()
355 + }, [cs.indicators, cs.tf, theme, refreshLastValues])
356 +
357 + // comparisons: load bars (paginated like the main series) then overlay
358 + useEffect(() => {
359 + const c = chartRef.current
360 + if (!c || !theme) return
361 + const want = new Map(cs.compares.filter(x => x.id && x.colorIndex != null).map(x => [x.id, x]))
362 + for (const [id, entry] of cmpRef.current) if (!want.has(id)) { entry.ctrl?.abort(); c.removeCompare(id); cmpRef.current.delete(id) }
363 + for (const [id, cmp] of want) {
364 + const key = `${cmp.asset}|${cmp.ticker}|${query.timeframe}`
365 + const prev = cmpRef.current.get(id)
366 + if (prev?.key === key) { if (prev.bars && prev.color !== seriesColor(theme, cmp.colorIndex ?? 0)) { prev.color = seriesColor(theme, cmp.colorIndex ?? 0); c.addCompare(id, cmp.ticker, prev.bars, prev.color) } continue }
367 + prev?.ctrl?.abort()
368 + const ctrl = new AbortController()
369 + const q = { asset: cmp.asset, ticker: cmp.ticker, timeframe: query.timeframe, adjustment: defaultAdjustment(cmp.asset) }
370 + const entry = { key, ctrl, q, bars: null, done: false, inflight: false, color: seriesColor(theme, cmp.colorIndex ?? 0) }
371 + cmpRef.current.set(id, entry)
372 + ;(async () => {
373 + try {
374 + const cached = getCached(q)
375 + let bars, complete = false
376 + if (cached?.bars?.length) { bars = cached.bars; complete = !!cached.startOfHistory }
377 + else { const res = await loadBars({ ...q, limit: INITIAL_BARS[q.timeframe], signal: ctrl.signal }); bars = res.bars; complete = res.complete; putCached(q, bars, { startOfHistory: complete }) }
378 + if (ctrl.signal.aborted) return
379 + entry.bars = bars; entry.done = complete
380 + c.addCompare(id, cmp.ticker, bars, entry.color)
381 + onChange?.({ compares: csRef.current.compares.map(x => (x.id === id ? { ...x, loading: false, error: null } : x)) })
382 + loadOlderComparesRef.current()
383 + } catch (e) {
384 + if (e?.name === 'AbortError') return
385 + onChange?.({ compares: csRef.current.compares.map(x => (x.id === id ? { ...x, loading: false, error: e.kind === 'not_found' ? 'not found' : e.kind === 'rate_limit' ? 'busy' : 'error' } : x)) })
386 + }
387 + })()
388 + }
389 + }, [cs.compares, query.timeframe, theme]) // eslint-disable-line react-hooks/exhaustive-deps
390 +
391 + // drawings: hide all / lock all
392 + useEffect(() => {
393 + const c = chartRef.current
394 + if (!c) return
395 + if (hiddenAll) {
396 + if (stashedDrawings.current == null) { stashedDrawings.current = c.getDrawings(); persistDrawings.current = false; c.setDrawings([]) }
397 + } else if (stashedDrawings.current != null) {
398 + const list = stashedDrawings.current
399 + stashedDrawings.current = null
400 + c.setDrawings(list)
401 + persistDrawings.current = true
402 + writeDrawings(csRef.current, list)
403 + }
404 + }, [hiddenAll])
405 + useEffect(() => {
406 + const c = chartRef.current
407 + if (!c || hiddenAll) return
408 + const list = c.getDrawings()
409 + if (!list.length || list.every(d => !!d.locked === !!lockedAll)) return
410 + c.setDrawings(list.map(d => ({ ...d, locked: !!lockedAll })))
411 + }, [lockedAll]) // eslint-disable-line react-hooks/exhaustive-deps
412 +
413 + // replay (v2 setReplay / v1 emulation by slicing the data)
414 + useEffect(() => {
415 + const c = chartRef.current
416 + if (!c) return
417 + const on = !!replay?.on
418 + if (on) {
419 + if (replayFull.current == null) { replayFull.current = c.getData(); olderRef.current.paused = true }
420 + const full = replayFull.current
421 + const idx = Math.max(0, Math.min(full.length - 1, replay.index ?? full.length - 1))
422 + if (has(c, 'setReplay')) { try { c.setReplay({ index: idx }) } catch { c.setData(full.slice(0, idx + 1)) } }
423 + else { const vr = c.getVisibleRange(); c.setData(full.slice(0, idx + 1)); if (vr && vr.toIndex > vr.fromIndex) c.setVisibleRange({ fromIndex: Math.max(0, idx - (vr.toIndex - vr.fromIndex)), toIndex: idx + 2 }, false) }
424 + requestAnimationFrame(() => refreshLastValues()) // indicator values are recomputed by the engine on its next frame
425 + } else if (replayFull.current != null) {
426 + const full = replayFull.current
427 + replayFull.current = null
428 + olderRef.current.paused = false
429 + if (has(c, 'setReplay')) { try { c.setReplay(null) } catch { c.setData(full) } } else c.setData(full)
430 + c.scrollToLatest(false)
431 + refreshLastValues()
432 + }
433 + }, [replay?.on, replay?.index, refreshLastValues])
434 +
435 + // ---- imperative API for the page -----------------------------------------------------------------------------------
436 + const api = useMemo(() => ({
437 + chart: () => chartRef.current,
438 + getData: () => chartRef.current?.getData() || [],
439 + getRange: () => range,
440 + decimals: () => decimals,
441 + focus: () => stageRef.current?.focus(),
442 + fitContent: () => chartRef.current?.fitContent(true),
443 + resetView: () => chartRef.current?.resetView(),
444 + undo: () => chartRef.current?.undo(), redo: () => chartRef.current?.redo(),
445 + deleteSelected: () => chartRef.current?.deleteSelectedDrawing(),
446 + clearDrawings: () => { stashedDrawings.current = null; chartRef.current?.clearDrawings() },
447 + retryOlder,
448 + /** Screenshot with the HTML legend when the engine supports it. */
449 + toPNG: async () => { const c = chartRef.current; if (!c) return null; const s = csRef.current; const opts = { scale: 2, watermark: `${s.label} · ${TF_LABEL[s.tf]} · hfmarketdata.io` }; try { return await c.toPNG({ ...opts, legend: true }) } catch { return c.toPNG(opts) } },
450 + toCSV: () => { const c = chartRef.current; if (!c) return ''; if (has(c, 'toCSV')) { try { return c.toCSV() } catch { /* fall through */ } } return barsToCsv(c.getData(), csRef.current.tf, timezoneLabel(csRef.current.asset)) },
451 + addHLine: price => { const c = chartRef.current; if (!c || price == null) return; const data = c.getData(); const t = hoverInfo.current?.bar?.t ?? data[data.length - 1]?.t ?? Date.now(); c.setDrawings([...c.getDrawings(), { id: `hl${Date.now().toString(36)}`, type: 'hline', points: [{ t, price }], style: { width: 1, dash: null }, locked: false }]) },
452 + addPriceLine: price => {
453 + const c = chartRef.current; if (!c || price == null) return
454 + if (has(c, 'addPriceLine')) { try { c.addPriceLine({ price, color: '#c98500', title: `Alert ${formatPrice(price, decimalsRef.current)}`, style: 'dashed' }); return } catch { /* fall back */ } }
455 + const data = c.getData(); const t = hoverInfo.current?.bar?.t ?? data[data.length - 1]?.t ?? Date.now()
456 + c.setDrawings([...c.getDrawings(), { id: `al${Date.now().toString(36)}`, type: 'hline', points: [{ t, price }], style: { color: '#c98500', width: 1, dash: [6, 4] }, text: `Alert ${formatPrice(price, decimalsRef.current)}`, locked: false }])
457 + },
458 + setDrawingStyle: (id, style) => { const c = chartRef.current; if (!c) return; if (has(c, 'setDrawingStyle')) { try { c.setDrawingStyle(id, style); return } catch { /* fall back */ } } c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, style: { ...(d.style || {}), ...style } } : d))) },
459 + setDrawingText: (id, text) => { const c = chartRef.current; if (!c) return; if (has(c, 'setDrawingText')) { try { c.setDrawingText(id, text); return } catch { /* fall back */ } } c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, text } : d))) },
460 + setDrawingLocked: (id, locked) => { const c = chartRef.current; if (!c) return; c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, locked } : d))) },
461 + duplicateDrawing: id => { const c = chartRef.current; if (!c) return null; const d = c.getDrawings().find(x => x.id === id); if (!d) return null; const shift = 5 * TF_MS[csRef.current.tf]; const copy = { ...d, id: `${d.id}-c${Date.now().toString(36)}`, points: d.points.map(p => ({ ...p, t: p.t + shift })), locked: false }; c.setDrawings([...c.getDrawings(), copy]); if (has(c, 'drawingSelect')) { try { c.drawingSelect(copy.id) } catch { /* ignore */ } } return copy.id },
462 + deleteDrawing: id => { const c = chartRef.current; if (!c) return; c.setDrawings(c.getDrawings().filter(d => d.id !== id)); onSelection?.(index, null) },
463 + selectDrawing: id => { const c = chartRef.current; if (has(c, 'drawingSelect')) { try { c.drawingSelect(id) } catch { /* ignore */ } } },
464 + /** Sibling synchronisation. */
465 + syncCrosshair: info => { const c = chartRef.current; if (!c) return; if (has(c, 'setCrosshairPosition')) { try { c.setCrosshairPosition(info ? { t: info.t, price: info.price } : null) } catch { /* ignore */ } } },
466 + syncRange: r => { const c = chartRef.current; if (!c || !r || r.fromT == null || r.toT == null) return; syncingRange.current = true; try { c.setVisibleRange({ fromT: r.fromT, toT: r.toT }, false) } finally { setTimeout(() => { syncingRange.current = false }, 0) } },
467 + }), [range, decimals, retryOlder, index, onSelection])
468 + useImperativeHandle(ref, () => api, [api])
469 + const apiRef = useRef(api); apiRef.current = api
470 +
471 + // ---- render --------------------------------------------------------------------------------------------------------
472 + const err = status.error
473 + const stale = status.phase === 'loading' && status.count > 0
474 + const replayBar = replay?.on && replayFull.current ? replayFull.current[Math.min(replayFull.current.length - 1, replay.index ?? 0)] : null
475 +
476 + return (
477 + <div className={`ch-view ${active ? 'is-active' : ''} ${stale ? 'is-stale' : ''}`} ref={stageRef} tabIndex={-1} data-testid={`ch-view-${index}`} onPointerDownCapture={() => onActivate?.(index)} onFocusCapture={() => onActivate?.(index)}>
478 + <div className="ch-canvas" ref={containerRef} data-testid={index === 0 ? 'ch-canvas' : `ch-canvas-${index}`} aria-label={`${cs.label} ${TF_LABEL[cs.tf]} price chart`} role="img" />
479 + {status.phase !== 'error' && (
480 + <Legend cs={cs} name={name} hover={hover} lastBar={lastBars.last} prevBar={lastBars.prev} indValues={hover ? hover.indicators : indValues} cmpValues={hover?.compares} plotColors={plotColors} decimals={decimals} tz={tz} theme={theme || { series: [] }}
481 + onOpenSettings={id => onChange?.({ _openSettings: id })} onToggleHidden={id => onChange?.({ indicators: cs.indicators.map(i => (i.id === id ? { ...i, hidden: !i.hidden } : i)) })}
482 + onRemoveIndicator={id => onChange?.({ indicators: cs.indicators.filter(i => i.id !== id) })} onRemoveCompare={id => onChange?.({ compares: cs.compares.filter(c => c.id !== id) })}
483 + compact={isMobile} describe={describe} replayLabel={replayBar ? `Replay · ${formatStampLabel(replayBar.t, cs.tf, cs.tf === '1day' ? '' : tz)}` : null} />
484 + )}
485 + {textEdit && (
486 + <form className="ch-textedit" style={{ left: textEdit.x, top: textEdit.y }} onSubmit={e => { e.preventDefault(); apiRef.current.setDrawingText(textEdit.id, textEdit.text); setTextEdit(null) }} data-testid="ch-textedit">
487 + <input autoFocus className="input mono" value={textEdit.text} aria-label="Drawing text" onChange={e => setTextEdit(t => ({ ...t, text: e.target.value }))} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Escape') setTextEdit(null) }} onBlur={() => { apiRef.current.setDrawingText(textEdit.id, textEdit.text); setTextEdit(null) }} />
488 + </form>
489 + )}
490 + {status.phase === 'loading' && status.count === 0 && (
491 + <div className="ch-skeleton" aria-busy="true" aria-label="Loading chart" data-testid="ch-skeleton">
492 + <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>
493 + <Skeleton width="40%" height="12px" />
494 + </div>
495 + )}
496 + {status.phase === 'error' && err && (
497 + <div className="ch-overlay" data-testid="ch-error">
498 + {err.kind === 'not_found' ? (
499 + <ErrorState status={404} code={err.code || 'TICKER_NOT_FOUND'} title={`${cs.label} not found`} message={`${cs.label} is not in the ${assetLabel(cs.asset)} dataset for ${TF_LABEL[cs.tf]} bars.`}
500 + actions={<Button size="sm" variant="ghost" onClick={() => onChange?.({ _focusSymbol: true })}>Search another symbol</Button>}>
501 + {suggestions.length > 0 && (
502 + <div className="ch-suggest" data-testid="ch-suggestions">
503 + <span className="muted small">Did you mean</span>
504 + {suggestions.map(s => <button key={`${s.asset}:${s.ticker}`} type="button" className="ch-chip mono" onClick={() => onChange?.({ asset: s.asset || 'stock', ticker: s.ticker, label: s.label || s.ticker, adjustment: '' })}>{s.ticker}<small>{assetLabel(s.asset)}</small></button>)}
505 + </div>
506 + )}
507 + </ErrorState>
508 + ) : (
509 + // generic transient error (429 burst guard, 5xx, network): no quota talk, just retry
510 + <ErrorState status={err.kind === 'network' ? 0 : 503} code={err.code} title={err.kind === 'network' ? undefined : 'Temporarily unavailable'} message={barsErrorMessage(err, cs.label)} onRetry={() => onChange?.({ _retry: Date.now() })} />
511 + )}
512 + </div>
513 + )}
514 + </div>
515 + )
516 +})
517 +
518 +export default ChartView
modified hfmarketdata/web/src/pages/charts/ChartsPage.jsx +313 −353
@@ -1,28 +1,33 @@
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.
1 +// /charts — full-screen charting on top of the API, no account and no quota needed (the API recognises the site's
2 +// own requests). Layouts of 1 / 2 / 4 charts (each with its symbol / timeframe, optional symbol + crosshair + time
3 +// sync), indicator library and settings dialogs, drawing bar with flyouts + floating properties, bar replay, chart
4 +// templates, context menu, shortcuts panel, URL state. One `ChartView` per chart owns its engine instance; this
5 +// page owns the toolbar (bound to the active chart), the dialogs, the keyboard and the URL.
4 6 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5 7 import { useLocation } from 'react-router-dom'
6 −import { useAuth } from '../../app/auth.jsx'
7 8 import useTitle from '../../docs/useTitle.js'
8 9 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'
10 +import { TF_LABEL } from '../../charts/data/bars.js'
11 +import { LAYOUTS, initialState, parseSearch, readFavorites, readLastTools, readPrefs, toSearch, writeFavorites, writeLast, writeLastTools, writePrefs } from '../../charts/data/state.js'
12 +import { pushRecent } from '../../charts/data/symbols.js'
13 +import { defaultTemplate, deleteTemplate, parseTemplateJSON, readTemplates, saveTemplate, setDefaultTemplate, templateFrom, templatesJSON, writeTemplates } from '../../charts/data/templates.js'
14 +import { defaultParams, indicatorDef, sanitizeParams } from './indicators.js'
15 +import { toolGroup } from './drawtools.js'
16 +import { buildTheme } from './theme.js'
18 17 import Toolbar from './Toolbar.jsx'
19 −import Legend from './Legend.jsx'
20 18 import DrawingBar, { TOOL_KEYS } from './DrawingBar.jsx'
19 +import DrawingProps from './DrawingProps.jsx'
21 20 import StatusBar from './StatusBar.jsx'
22 21 import BarsTable from './BarsTable.jsx'
23 22 import BottomSheet from './BottomSheet.jsx'
24 −import { AdjustmentPanel, ComparePanel, IndicatorsPanel, ScalePanel, SeriesTypePanel, SettingsPanel } from './panels.jsx'
25 −import { CameraIcon, ExpandIcon, TableIcon } from './icons.jsx'
23 +import ChartView from './ChartView.jsx'
24 +import IndicatorLibrary from './IndicatorLibrary.jsx'
25 +import IndicatorSettings from './IndicatorSettings.jsx'
26 +import ReplayBar from './ReplayBar.jsx'
27 +import ContextMenu from './ContextMenu.jsx'
28 +import ShortcutsDialog from './ShortcutsDialog.jsx'
29 +import { AdjustmentPanel, ComparePanel, IndicatorsPanel, LayoutPanel, ScalePanel, SeriesTypePanel, SettingsPanel, TemplatesPanel } from './panels.jsx'
30 +import { CameraIcon, ExpandIcon, KeyboardIcon, ReplayIcon, TableIcon } from './icons.jsx'
26 31 import CopyButton from '../../components/CopyButton.jsx'
27 32 import './charts.css'
28 33
@@ -30,443 +35,398 @@ let seq = 0
30 35 const nextId = prefix => `${prefix}${++seq}-${Date.now().toString(36)}`
31 36 const isDebug = () => import.meta.env.DEV || new URLSearchParams(window.location.search).get('debug') === '1'
32 37 const isTyping = e => { const t = e.target?.tagName; return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || e.target?.isContentEditable }
38 +const ADJ_ASSETS = new Set(['stock', 'etf', 'futures'])
39 +const LAYOUT_KEYS = ['1', '2h', '2v', '4']
33 40
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))
41 +function nextColorIndex(cs) {
42 + const used = new Set([...cs.indicators.map(i => i.colorIndex), ...cs.compares.map(c => c.colorIndex)].filter(x => x != null))
36 43 for (let i = 0; i < 64; i++) if (!used.has(i)) return i
37 44 return used.size
38 45 }
46 +/** Give ids / colours to indicators & compares that came from the URL or a template. */
47 +function normalizeChart(cs) {
48 + if (cs.indicators.every(i => i.id && i.colorIndex != null) && cs.compares.every(c => c.id && c.colorIndex != null)) return cs
49 + const next = { ...cs, indicators: [], compares: [] }
50 + for (const i of cs.indicators) next.indicators.push({ ...i, id: i.id || nextId('ind'), colorIndex: i.colorIndex ?? nextColorIndex(next) })
51 + for (const c of cs.compares) next.compares.push({ ...c, id: c.id || nextId('cmp'), asset: c.asset || 'stock', colorIndex: c.colorIndex ?? nextColorIndex(next), loading: c.loading ?? true })
52 + return next
53 +}
54 +function withTemplate(cs, t) {
55 + if (!t) return cs
56 + const out = { ...cs }
57 + if (t.type) out.type = t.type
58 + if (t.scale) out.scale = t.scale
59 + if (typeof t.volume === 'boolean') out.volume = t.volume
60 + out.indicators = (t.indicators || []).map(i => ({ type: i.type, params: indicatorDef(i.type) ? sanitizeParams(indicatorDef(i.type), i.params) : { ...(i.params || {}) }, pane: i.pane || indicatorDef(i.type)?.pane, plots: i.plots, hidden: !!i.hidden, visibility: i.visibility }))
61 + return normalizeChart(out)
62 +}
39 63
40 64 export default function ChartsPage() {
41 65 const location = useLocation()
42 − const { user } = useAuth()
43 66 const isMobile = useMediaQuery('(max-width: 640px)')
44 − const [state, setState] = useState(() => initialState(location.search))
67 + const [init] = useState(() => {
68 + const st = initialState(location.search)
69 + const parsed = parseSearch(location.search)
70 + const tpl = defaultTemplate()
71 + st.charts = st.charts.map((cs, k) => {
72 + const u = parsed.charts[k] || {}
73 + const fresh = !u.indicators && !u.type
74 + return normalizeChart(fresh && tpl ? withTemplate(cs, tpl) : cs)
75 + })
76 + return st
77 + })
78 + const [page, setPage] = useState(init.page)
79 + const [charts, setCharts] = useState(init.charts)
45 80 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)
81 + const [favorites, setFavorites] = useState(readFavorites)
82 + const [templates, setTemplates] = useState(readTemplates)
83 + const [lastTools, setLastTools] = useState(readLastTools)
84 + const [statuses, setStatuses] = useState({})
85 + const [reloadKeys, setReloadKeys] = useState({})
86 + const [drawingsCount, setDrawingsCount] = useState({})
52 87 const [table, setTable] = useState(false)
53 − const [sheet, setSheet] = useState(null) // null | 'tools' | 'type' | 'indicators' | 'compare' | 'adjustment' | 'scale' | 'settings'
88 + const [sheet, setSheet] = useState(null) // null | 'tools' | 'type' | 'indicators' | 'compare' | 'adjustment' | 'scale' | 'settings' | 'layout' | 'templates' | 'props'
54 89 const [drawbarOpen, setDrawbarOpen] = useState(false)
55 90 const [fullscreen, setFullscreen] = useState(false)
56 91 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)
92 + const [hiddenAll, setHiddenAll] = useState(false)
93 + const [lockedAll, setLockedAll] = useState(false)
94 + const [selection, setSelection] = useState(null) // { chartIndex, id, type, style, text, locked }
95 + const [ctxMenu, setCtxMenu] = useState(null)
96 + const [library, setLibrary] = useState(false)
97 + const [settingsFor, setSettingsFor] = useState(null) // { chartIndex, id }
98 + const [shortcuts, setShortcuts] = useState(false)
99 + const [replay, setReplay] = useState({ on: false, chartIndex: 0, index: 0, playing: false, speed: 1, jumping: false })
100 + const [catalogTick, setCatalogTick] = useState(0)
61 101
62 102 const rootRef = useRef(null)
63 − const containerRef = useRef(null)
64 − const chartRef = useRef(null)
65 103 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)
104 + const viewRefs = useRef([])
105 + const chartsRef = useRef(charts); chartsRef.current = charts
106 + const pageRef = useRef(page); pageRef.current = page
107 + const prefsRef = useRef(prefs); prefsRef.current = prefs
72 108 const lastSearchRef = useRef(null)
73 − const hoverRaf = useRef(0)
74 − stateRef.current = state
109 + const replayTimer = useRef(0)
75 110
76 − const tz = timezoneLabel(state.asset)
77 − const authenticated = !!user || !!apiKey
111 + const count = LAYOUTS[page.layout]?.count || 1
112 + const active = Math.min(page.active, count - 1)
113 + const cs = charts[active] || charts[0]
114 + const status = statuses[active] || { phase: 'loading', count: 0 }
78 115 const theme = useMemo(() => (typeof document !== 'undefined' ? buildTheme({ colorblind: prefs.colorblind }) : null), [prefs.colorblind]) // eslint-disable-line react-hooks/exhaustive-deps
79 − themeRef.current = theme
116 + const debug = isDebug()
80 117
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.`)
118 + useTitle(`${cs.label} ${TF_LABEL[cs.tf]} · Charts`, `Interactive ${cs.label} ${TF_LABEL[cs.tf]} chart — candles, 40+ indicators, comparisons, drawing tools, bar replay and multi-chart layouts on HF Market Data's free market data API. No account, no limit.`)
82 119
83 − // ---- chart lifecycle (once) --------------------------------------------------------------------------------
84 − useEffect(() => {
85 − const el = containerRef.current
86 − if (!el) return undefined
87 − const s = stateRef.current
88 − // bars shown by fitContent(): ~9 px per bar so phones start on ~45 readable candles, desktops on ~150
89 − const fitBarsFor = width => Math.max(40, Math.min(200, Math.round(width / 9)))
90 − const chart = createChart(el, { fitBars: fitBarsFor(el.clientWidth || window.innerWidth), 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 })
91 − chartRef.current = chart
92 − if (isDebug()) window.__hfmdChart = chart
93 − const offs = [
94 − chart.on('visibleRangeChange', r => {
95 − setRange(r)
96 − if (r.needMoreLeft) loadOlderRef.current()
97 − }),
98 − chart.on('crosshairMove', info => {
99 − cancelAnimationFrame(hoverRaf.current)
100 − hoverRaf.current = requestAnimationFrame(() => {
101 − if (!info) { setHover(null); return }
102 − const data = chart.getData()
103 − setHover({ bar: info.bar, prev: data[info.index - 1] || null, indicators: info.indicators || {}, compares: info.compares || {} })
104 − })
105 − }),
106 − chart.on('drawingsChange', list => { writeDrawings(stateRef.current, list); setHasDrawings(list.length > 0) }),
107 − chart.on('toolChange', t => setTool(t)),
108 − ]
109 − const onTheme = () => chart.setTheme(buildTheme({ colorblind: readPrefs().colorblind }))
110 − window.addEventListener('hfmd:theme', onTheme)
111 − const onResize = () => chart.setOptions({ fitBars: fitBarsFor(el.clientWidth || window.innerWidth) })
112 − window.addEventListener('resize', onResize)
113 − return () => {
114 − offs.forEach(off => off())
115 − window.removeEventListener('hfmd:theme', onTheme)
116 − window.removeEventListener('resize', onResize)
117 − cancelAnimationFrame(hoverRaf.current)
118 − abortRef.current?.abort()
119 − chart.destroy()
120 − chartRef.current = null
121 − if (window.__hfmdChart === chart) delete window.__hfmdChart
122 − }
123 − }, []) // eslint-disable-line react-hooks/exhaustive-deps
120 + // ---- per-chart state helpers -------------------------------------------------------------------------------------
121 + const patchChart = useCallback((i, patch) => setCharts(list => list.map((c, k) => (k === i ? normalizeChart({ ...c, ...(typeof patch === 'function' ? patch(c) : patch) }) : c))), [])
122 + const patchActive = useCallback(patch => patchChart(pageRef.current.active, patch), [patchChart])
124 123
125 − // ---- helpers ------------------------------------------------------------------------------------------------------
126 − const refreshLastValues = useCallback(() => {
127 − const chart = chartRef.current
124 + const onChartChange = useCallback((i, patch) => {
125 + if ('_drawings' in patch) { setDrawingsCount(d => (d[i] === patch._drawings ? d : { ...d, [i]: patch._drawings })); return }
126 + if ('_openSettings' in patch) { setSettingsFor({ chartIndex: i, id: patch._openSettings }); return }
127 + if ('_focusSymbol' in patch) { symbolRef.current?.focus(); return }
128 + if ('_retry' in patch) { setReloadKeys(k => ({ ...k, [i]: (k[i] || 0) + 1 })); return }
129 + patchChart(i, patch)
130 + }, [patchChart])
131 + const onStatus = useCallback((i, st) => setStatuses(s => ({ ...s, [i]: st })), [])
132 + const onSelection = useCallback((i, sel) => setSelection(sel ? { ...sel, chartIndex: i } : null), [])
133 + const onContextMenu = useCallback(info => { setCtxMenu(info); setPage(p => (p.active === info.chartIndex ? p : { ...p, active: info.chartIndex })) }, [])
134 + const onActivate = useCallback(i => setPage(p => (p.active === i ? p : { ...p, active: i })), [])
135 + const onToolChange = useCallback(t => setTool(t), [])
136 + const onFirstCatalog = useCallback(() => setCatalogTick(t => t + 1), [])
137 +
138 + // sync between charts
139 + const onCrosshair = useCallback((i, info) => {
140 + if (!prefsRef.current.syncCrosshair || (LAYOUTS[pageRef.current.layout]?.count || 1) < 2) return
141 + viewRefs.current.forEach((v, k) => { if (k !== i) v?.syncCrosshair(info ? { t: info.bar?.t, price: info.price } : null) })
142 + }, [])
143 + const onRange = useCallback((i, r) => {
144 + if (!prefsRef.current.syncTime || (LAYOUTS[pageRef.current.layout]?.count || 1) < 2) return
145 + const view = viewRefs.current[i]
146 + const chart = view?.chart?.()
128 147 if (!chart) return
129 − const data = chart.getData()
130 − setLastBars({ last: data[data.length - 1] || null, prev: data[data.length - 2] || null })
131 − const vals = {}
132 − for (const ind of chart.getIndicators()) {
133 − vals[ind.id] = {}
134 − // only the plotted keys (engine names: sma, upper/middle/lower, macd/signal/hist…), not helper arrays
135 − const keys = ind.plotColors && Object.keys(ind.plotColors).length ? Object.keys(ind.plotColors) : Object.keys(ind.values || {})
136 − for (const k of keys) {
137 − const arr = ind.values?.[k]
138 − // the engine stores plots as typed arrays (Float64Array): not Array.isArray, NaN = no value yet
139 − const v = arr && typeof arr.length === 'number' && arr.length ? arr[arr.length - 1] : null
140 − vals[ind.id][k] = v == null || Number.isNaN(v) ? null : v
141 − }
142 − }
143 − setIndValues(vals)
148 + const vr = chart.getVisibleRange()
149 + viewRefs.current.forEach((v, k) => { if (k !== i) v?.syncRange(vr) })
144 150 }, [])
145 151
146 − 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])
147 − const queryRef = useRef(query)
148 − queryRef.current = query
149 −
150 − // ---- initial load per (asset, ticker, tf, adjustment) -----------------------------------------------------------
151 − useEffect(() => {
152 − const chart = chartRef.current
153 − if (!chart) return undefined
154 − abortRef.current?.abort()
155 − const ctrl = new AbortController()
156 − abortRef.current = ctrl
157 − olderRef.current = { inflight: false, done: false }
158 − setSuggestions([])
159 − chart.setTimeframe(query.timeframe)
160 − chart.setOptions({ sessionLabel: timezoneLabel(query.asset), watermark: prefs.watermark ? `${state.label} · ${TF_LABEL[query.timeframe]} · HF Market Data` : undefined })
161 − chart.setDrawings(readDrawings({ asset: query.asset, ticker: query.ticker, tf: query.timeframe }))
162 − setHasDrawings(readDrawings({ asset: query.asset, ticker: query.ticker, tf: query.timeframe }).length > 0)
163 − writeLast(state)
164 −
165 − const apply = (bars, extra) => {
166 − chart.setData(bars)
167 − chart.fitContent(false)
168 − const fmt = priceFormatFor(query.asset, bars, extra?.spec)
169 − setDecimals(fmt.decimals)
170 − chart.setOptions({ priceFormat: fmt })
171 − refreshLastValues()
172 − 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 }))
173 − olderRef.current.done = !!extra?.startOfHistory
174 − }
175 − // spec / name (futures) and contract metadata, best effort, cached
176 − let contractMeta = null
177 − const metaP = (async () => {
178 − try {
179 − if (query.asset === 'futures') { const spec = await loadRootSpec(query.ticker, { apiKey, signal: ctrl.signal }); if (spec) setName(spec.name || ''); return spec }
180 − 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 } }
181 − setName('')
182 − } catch { setName('') }
183 − return null
184 − })()
185 −
186 − const cached = getCached(query)
187 − if (cached?.bars?.length) { metaP.then(spec => apply(cached.bars, { spec, startOfHistory: cached.startOfHistory, rate: cached.rate })); return () => ctrl.abort() }
188 −
189 − setStatus(st => ({ ...st, phase: 'loading', error: null, loadingOlder: false, startOfHistory: false }))
190 − ;(async () => {
191 − try {
192 − const [{ maxRows }, spec] = await Promise.all([loadMaxRows({ apiKey }), metaP])
193 − const limit = Math.min(INITIAL_BARS[query.timeframe] || 1500, maxRows)
194 − const res = await loadBars({ ...query, limit, apiKey, signal: ctrl.signal, firstDate: contractMeta?.firstDate })
195 − if (ctrl.signal.aborted) return
196 − const entry = putCached(query, res.bars, { startOfHistory: res.complete, rate: res.rate })
197 − apply(entry.bars, { spec, startOfHistory: res.complete, rate: res.rate })
198 − } catch (e) {
199 − if (e?.name === 'AbortError' || ctrl.signal.aborted) return
200 − setStatus(st => ({ ...st, phase: 'error', error: e, rate: e.rate || st.rate }))
201 − if (e.kind === 'not_found') {
202 − 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(() => {})
203 − }
204 − }
205 − })()
206 − return () => ctrl.abort()
207 − }, [query, apiKey]) // eslint-disable-line react-hooks/exhaustive-deps
208 −
209 − // ---- infinite history ----------------------------------------------------------------------------------------
210 − const loadOlderRef = useRef(() => {})
211 − loadOlderRef.current = async () => {
212 − const chart = chartRef.current
213 − const q = queryRef.current
214 − if (!chart || olderRef.current.inflight || olderRef.current.done) return
215 − const data = chart.getData()
216 − if (!data.length) return
217 − olderRef.current.inflight = true
218 − setStatus(st => ({ ...st, loadingOlder: true }))
219 − const ctrl = abortRef.current
220 − try {
221 − const { maxRows } = await loadMaxRows({ apiKey })
222 − const limit = Math.min(INITIAL_BARS[q.timeframe] || 1500, maxRows)
223 − const res = await loadBars({ ...q, end: data[0].t, limit, apiKey, signal: ctrl?.signal })
224 − if (ctrl?.signal.aborted || queryRef.current !== q) return
225 − const older = res.bars.filter(b => b.t < data[0].t)
226 − putCached(q, older, { startOfHistory: res.complete, rate: res.rate })
227 − if (older.length) chart.prependData(older)
228 − if (res.complete) olderRef.current.done = true
229 − const all = chart.getData()
230 − setStatus(st => ({ ...st, loadingOlder: false, count: all.length, firstT: all[0]?.t ?? null, startOfHistory: res.complete, rate: res.rate || st.rate }))
231 − } catch (e) {
232 − if (e?.name === 'AbortError') return
233 − // a 429 / network error while paginating: stop quietly, keep what we have, surface the quota in the status bar
234 − olderRef.current.done = e.kind === 'not_found'
235 − setStatus(st => ({ ...st, loadingOlder: false, rate: e.rate || st.rate, olderError: e }))
236 − } finally {
237 − olderRef.current.inflight = false
238 − }
239 − }
240 −
241 − // ---- option sync (never re-creates the chart) -------------------------------------------------------------------
242 − const chartCall = (fn, deps) => useEffect(() => { const c = chartRef.current; if (c) fn(c) }, deps) // eslint-disable-line react-hooks/rules-of-hooks
243 − chartCall(c => c.setSeriesType(prefs.colorblind && state.type === 'candles' ? 'hollow' : state.type), [state.type, prefs.colorblind])
244 − chartCall(c => c.setPriceScale({ mode: state.compares.length ? 'percent' : state.scale, auto: prefs.autoScale }), [state.scale, state.compares.length, prefs.autoScale])
245 − chartCall(c => c.setVolume(state.volume), [state.volume])
246 − chartCall(c => { if (theme) c.setTheme(theme) }, [theme])
247 − chartCall(c => c.setCrosshair({ mode: prefs.magnet ? 'magnet' : 'normal', showLabels: true }), [prefs.magnet])
248 − 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])
249 −
250 − // indicators reconciliation
251 − useEffect(() => {
252 − const c = chartRef.current
253 − if (!c || !theme) return
254 − const want = new Map(state.indicators.filter(i => i.id && i.colorIndex != null).map(i => [i.id, i]))
255 − for (const [id] of appliedInd.current) if (!want.has(id)) { c.removeIndicator(id); appliedInd.current.delete(id) }
256 − for (const [id, ind] of want) {
257 − const key = JSON.stringify(ind.params)
258 − const prev = appliedInd.current.get(id)
259 − 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) }
260 − else if (prev !== key) { c.updateIndicator(id, ind.params); appliedInd.current.set(id, key) }
261 − }
262 − refreshLastValues()
263 − }, [state.indicators, theme, refreshLastValues])
264 −
265 − // comparisons: load bars then overlay
266 − useEffect(() => {
267 − const c = chartRef.current
268 − if (!c || !theme) return
269 − const want = new Map(state.compares.filter(x => x.id && x.colorIndex != null).map(x => [x.id, x]))
270 − for (const [id, entry] of appliedCmp.current) if (!want.has(id)) { entry.ctrl?.abort(); c.removeCompare(id); appliedCmp.current.delete(id) }
271 − for (const [id, cmp] of want) {
272 − const key = `${cmp.asset}|${cmp.ticker}|${query.timeframe}`
273 − const prev = appliedCmp.current.get(id)
274 − if (prev?.key === key) continue
275 − prev?.ctrl?.abort()
276 − const ctrl = new AbortController()
277 − appliedCmp.current.set(id, { key, ctrl })
278 − ;(async () => {
279 − try {
280 − const { maxRows } = await loadMaxRows({ apiKey })
281 − const q = { asset: cmp.asset, ticker: cmp.ticker, timeframe: query.timeframe, adjustment: defaultAdjustment(cmp.asset) }
282 − const cached = getCached(q)
283 − const bars = cached?.bars?.length ? cached.bars : (await loadBars({ ...q, limit: Math.min(INITIAL_BARS[q.timeframe] || 1500, maxRows), apiKey, signal: ctrl.signal })).bars
284 − if (ctrl.signal.aborted) return
285 − if (!cached?.bars?.length) putCached(q, bars)
286 − c.addCompare(id, cmp.ticker, bars, seriesColor(theme, cmp.colorIndex ?? 0))
287 − setState(s => ({ ...s, compares: s.compares.map(x => (x.id === id ? { ...x, loading: false, error: null } : x)) }))
288 − } catch (e) {
289 − if (e?.name === 'AbortError') return
290 − 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)) }))
291 − }
292 − })()
293 − }
294 − }, [state.compares, query.timeframe, theme, apiKey]) // eslint-disable-line react-hooks/exhaustive-deps
295 −
296 152 // ---- URL sync (replaceState, debounced) + external navigation --------------------------------------------------
297 153 useEffect(() => {
298 − const search = toSearch(state)
154 + const search = toSearch(page, charts)
299 155 const t = setTimeout(() => {
300 156 if (window.location.pathname !== '/charts') return
301 157 if (window.location.search !== search) { lastSearchRef.current = search; window.history.replaceState(window.history.state, '', `/charts${search}${window.location.hash}`) }
302 158 }, 250)
303 159 return () => clearTimeout(t)
304 − }, [state])
160 + }, [page, charts])
305 161 useEffect(() => {
306 162 if (location.search === lastSearchRef.current || !location.search) return
307 163 const parsed = parseSearch(location.search)
308 − if (parsed.ticker && (parsed.ticker !== stateRef.current.ticker || (parsed.tf && parsed.tf !== stateRef.current.tf))) {
309 − 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 }))
164 + const u = parsed.charts[0]
165 + const cur = chartsRef.current[0]
166 + if (u?.ticker && (u.ticker !== cur.ticker || (u.tf && u.tf !== cur.tf))) {
167 + patchChart(0, c => ({ ...c, ...u, label: u.ticker, indicators: (u.indicators || c.indicators), compares: c.compares }))
310 168 }
311 − }, [location.search])
312 −
313 − // assign ids / colours to indicators & compares that came from the URL
314 − useEffect(() => {
315 − if (state.indicators.every(i => i.id && i.colorIndex != null) && state.compares.every(c => c.id && c.colorIndex != null)) return
316 − setState(s => {
317 − let next = { ...s, indicators: [], compares: [] }
318 − for (const i of s.indicators) { next.indicators.push({ ...i, id: i.id || nextId('ind'), colorIndex: i.colorIndex ?? nextColorIndex(next) }) }
319 − 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 }) }
320 − return next
321 − })
322 − }, [state.indicators, state.compares])
169 + if (parsed.layout && parsed.layout !== pageRef.current.layout) setLayoutInternal(parsed.layout)
170 + }, [location.search]) // eslint-disable-line react-hooks/exhaustive-deps
323 171
172 + useEffect(() => { writeLast(charts[0]) }, [charts[0].asset, charts[0].ticker, charts[0].tf, charts[0].type]) // eslint-disable-line react-hooks/exhaustive-deps
173 + useEffect(() => { pushRecent({ asset: cs.asset, ticker: cs.ticker, label: cs.label }) }, [cs.asset, cs.ticker, cs.label])
324 174 useEffect(() => { writePrefs(prefs) }, [prefs])
325 − useEffect(() => { pushRecent({ asset: state.asset, ticker: state.ticker, label: state.label }) }, [state.asset, state.ticker, state.label])
175 + useEffect(() => { writeFavorites(favorites) }, [favorites])
176 + useEffect(() => { writeLastTools(lastTools) }, [lastTools])
326 177 useEffect(() => {
327 178 const on = () => setFullscreen(!!document.fullscreenElement)
328 179 document.addEventListener('fullscreenchange', on)
329 180 return () => document.removeEventListener('fullscreenchange', on)
330 181 }, [])
331 182 useEffect(() => { if (!isMobile) setSheet(null) }, [isMobile])
183 + // any symbol / timeframe change ends a replay (the engine data is reloaded)
184 + useEffect(() => { setReplay(r => (r.on ? { ...r, on: false, playing: false, jumping: false } : r)) }, [cs.asset, cs.ticker, cs.tf, cs.adjustment])
185 + // replay clock
186 + useEffect(() => {
187 + clearInterval(replayTimer.current)
188 + if (!replay.on || !replay.playing) return undefined
189 + replayTimer.current = setInterval(() => setReplay(r => {
190 + const max = (replayTotals.current[r.chartIndex] || 1) - 1
191 + if (r.index >= max) return { ...r, playing: false }
192 + return { ...r, index: Math.min(max, r.index + 1) }
193 + }), Math.max(60, 600 / replay.speed))
194 + return () => clearInterval(replayTimer.current)
195 + }, [replay.on, replay.playing, replay.speed, replay.chartIndex])
196 +
197 + // ---- layout ---------------------------------------------------------------------------------------------------------
198 + const setLayoutInternal = useCallback(layout => {
199 + const n = LAYOUTS[layout]?.count
200 + if (!n) return
201 + setCharts(list => {
202 + const out = list.slice(0, n)
203 + const tpl = defaultTemplate()
204 + while (out.length < n) { const first = out[0]; out.push(normalizeChart(withTemplate({ ...first, indicators: [], compares: [], label: first.label }, tpl))) }
205 + return out
206 + })
207 + setPage(p => ({ ...p, layout, active: Math.min(p.active, n - 1) }))
208 + setSelection(null)
209 + setReplay(r => ({ ...r, on: false, playing: false }))
210 + }, [])
332 211
333 212 // ---- actions ------------------------------------------------------------------------------------------------------
334 213 const setPrefs = useCallback(p => setPrefsState(p), [])
335 214 const a = useMemo(() => ({
336 − 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) },
337 − setTf: tf => setState(s => ({ ...s, tf })),
338 − setType: type => { setState(s => ({ ...s, type })); setSheet(null) },
339 − setScale: scale => setState(s => ({ ...s, scale })),
340 − setVolume: volume => setState(s => ({ ...s, volume })),
341 − setAdjustment: adjustment => { setState(s => ({ ...s, adjustment })); setSheet(null) },
215 + pickSymbol: item => {
216 + if (!item?.ticker) return
217 + const patch = c => ({ asset: item.asset || 'stock', ticker: item.ticker, label: item.label || item.ticker, adjustment: item.asset === c.asset ? c.adjustment : '' })
218 + if (prefsRef.current.syncSymbol && (LAYOUTS[pageRef.current.layout]?.count || 1) > 1) setCharts(list => list.map(c => normalizeChart({ ...c, ...patch(c) })))
219 + else patchActive(patch)
220 + setSheet(null)
221 + },
222 + setTf: tf => patchActive({ tf }),
223 + setType: type => { patchActive({ type }); setSheet(null) },
224 + setScale: scale => patchActive({ scale }),
225 + setVolume: volume => patchActive({ volume }),
226 + setAdjustment: adjustment => { patchActive({ adjustment }); setSheet(null) },
342 227 addIndicator: type => {
343 228 const def = indicatorDef(type)
344 229 if (!def) return
345 − setState(s => ({ ...s, indicators: [...s.indicators, { id: nextId('ind'), type, params: defaultParams(def), pane: def.pane, colorIndex: nextColorIndex(s) }] }))
230 + patchActive(c => ({ indicators: [...c.indicators, { id: nextId('ind'), type, params: defaultParams(def), pane: def.pane, colorIndex: nextColorIndex(c) }] }))
346 231 setSheet(null)
347 232 },
348 − updateIndicator: (id, params) => setState(s => ({ ...s, indicators: s.indicators.map(i => (i.id === id ? { ...i, params } : i)) })),
349 − removeIndicator: id => setState(s => ({ ...s, indicators: s.indicators.filter(i => i.id !== id) })),
233 + updateIndicator: (id, patch) => patchActive(c => ({ indicators: c.indicators.map(i => (i.id === id ? { ...i, ...patch } : i)) })),
234 + removeIndicator: id => patchActive(c => ({ indicators: c.indicators.filter(i => i.id !== id) })),
235 + toggleHidden: id => patchActive(c => ({ indicators: c.indicators.map(i => (i.id === id ? { ...i, hidden: !i.hidden } : i)) })),
236 + moveIndicator: (id, dir) => patchActive(c => { const list = c.indicators.slice(); const i = list.findIndex(x => x.id === id); const j = i + dir; if (i < 0 || j < 0 || j >= list.length) return {}; [list[i], list[j]] = [list[j], list[i]]; return { indicators: list } }),
350 237 addCompare: item => {
351 238 if (!item?.ticker) return
352 − setState(s => {
353 − if (s.compares.some(c => c.ticker === item.ticker && c.asset === (item.asset || 'stock'))) return s
354 − return { ...s, compares: [...s.compares, { id: nextId('cmp'), ticker: item.ticker, asset: item.asset || 'stock', colorIndex: nextColorIndex(s), loading: true }] }
355 − })
239 + patchActive(c => (c.compares.some(x => x.ticker === item.ticker && x.asset === (item.asset || 'stock')) ? {} : { compares: [...c.compares, { id: nextId('cmp'), ticker: item.ticker, asset: item.asset || 'stock', colorIndex: nextColorIndex(c), loading: true }] }))
356 240 },
357 − removeCompare: id => setState(s => ({ ...s, compares: s.compares.filter(c => c.id !== id) })),
241 + removeCompare: id => patchActive(c => ({ compares: c.compares.filter(x => x.id !== id) })),
358 242 setPref: (k, v) => setPrefsState(p => ({ ...p, [k]: v })),
359 243 setPrefs,
360 − setApiKey,
244 + toggleFavorite: id => setFavorites(f => (f.includes(id) ? f.filter(x => x !== id) : [...f, id])),
361 245 toggleTable: () => setTable(t => !t),
362 246 toggleDrawbar: () => setDrawbarOpen(v => !v),
363 247 openSheet: () => setSheet('tools'),
248 + openLibrary: () => { setLibrary(true); setSheet(null) },
249 + openShortcuts: () => { setShortcuts(true); setSheet(null) },
250 + setLayout: layout => { setLayoutInternal(layout); setSheet(null) },
364 251 screenshot: async () => {
365 − const c = chartRef.current
366 − if (!c) return
367 − const s = stateRef.current
368 − const blob = await c.toPNG({ scale: 2, watermark: `${s.label} · ${TF_LABEL[s.tf]} · hfmarketdata.io` })
252 + const view = viewRefs.current[pageRef.current.active]
253 + const blob = await view?.toPNG()
369 254 if (!blob) return
255 + const s = chartsRef.current[pageRef.current.active]
370 256 const url = URL.createObjectURL(blob)
371 257 const link = document.createElement('a'); link.href = url; link.download = `${s.ticker}-${TF_LABEL[s.tf]}.png`; link.click()
372 258 setTimeout(() => URL.revokeObjectURL(url), 1000)
373 259 },
374 260 toggleFullscreen: () => { if (document.fullscreenElement) document.exitFullscreen?.(); else rootRef.current?.requestFullscreen?.() },
375 − retry: () => setState(s => ({ ...s })),
376 − }), [setPrefs])
261 + // templates
262 + applyTemplate: t => { setCharts(list => list.map((c, k) => (k === pageRef.current.active ? withTemplate({ ...c, indicators: [] }, t) : c))); setSheet(null) },
263 + saveTemplate: name => setTemplates(saveTemplate(templateFrom(chartsRef.current[pageRef.current.active], name))),
264 + deleteTemplate: name => setTemplates(deleteTemplate(name)),
265 + setDefaultTemplate: name => setTemplates(setDefaultTemplate(name)),
266 + exportTemplates: () => {
267 + const blob = new Blob([templatesJSON(readTemplates())], { type: 'application/json' })
268 + const url = URL.createObjectURL(blob)
269 + const link = document.createElement('a'); link.href = url; link.download = 'hfmarketdata-chart-templates.json'; link.click()
270 + setTimeout(() => URL.revokeObjectURL(url), 1000)
271 + },
272 + importTemplates: text => {
273 + try {
274 + let list = readTemplates()
275 + for (const t of parseTemplateJSON(text)) { list = list.filter(x => x.name !== t.name); list.push(t) }
276 + const firstDefault = list.findIndex(t => t.isDefault)
277 + list = list.map((t, i) => ({ ...t, isDefault: t.isDefault && i === firstDefault }))
278 + writeTemplates(list)
279 + setTemplates(list)
280 + } catch (e) { window.alert(`Import failed: ${e.message}`) }
281 + },
282 + // replay
283 + toggleReplay: () => setReplay(r => {
284 + if (r.on) return { ...r, on: false, playing: false, jumping: false }
285 + const i = pageRef.current.active
286 + const total = viewRefs.current[i]?.getData().length || 0
287 + replayTotals.current[i] = total
288 + return { on: true, chartIndex: i, index: Math.max(0, total - Math.min(60, Math.floor(total / 3)) - 1), playing: false, speed: r.speed || 1, jumping: false }
289 + }),
290 + }), [patchActive, setPrefs, setLayoutInternal])
291 +
292 + const replayTotals = useRef({}) // full length of the series when the replay started, per chart
293 + const replayApi = useMemo(() => ({
294 + toggle: () => setReplay(r => ({ ...r, playing: !r.playing })),
295 + step: n => setReplay(r => ({ ...r, playing: false, index: Math.max(0, Math.min((replayTotals.current[r.chartIndex] || 1) - 1, r.index + n)) })),
296 + speed: speed => setReplay(r => ({ ...r, speed })),
297 + jumpMode: on => setReplay(r => ({ ...r, jumping: on })),
298 + seek: index => setReplay(r => ({ ...r, index, playing: false })),
299 + exit: () => setReplay(r => ({ ...r, on: false, playing: false, jumping: false })),
300 + }), [])
301 + const onReplayJump = useCallback((i, index) => setReplay(r => (r.on && r.chartIndex === i ? { ...r, index, jumping: false, playing: false } : r)), [])
377 302
378 − const setToolAction = useCallback(t => { chartRef.current?.setDrawingTool(t); setTool(t) }, [])
303 + const setToolAction = useCallback(t => { setTool(t); if (t) { const g = toolGroup(t); if (g) setLastTools(m => (m[g] === t ? m : { ...m, [g]: t })) } }, [])
304 +
305 + // ---- drawing properties (selection) -------------------------------------------------------------------------------
306 + const view = i => viewRefs.current[i]
307 + const propsApi = {
308 + onStyle: (id, style) => { view(selection.chartIndex)?.setDrawingStyle(id, style); setSelection(s => (s ? { ...s, style } : s)) },
309 + onText: (id, text) => { view(selection.chartIndex)?.setDrawingText(id, text); setSelection(s => (s ? { ...s, text } : s)) },
310 + onLock: (id, locked) => { view(selection.chartIndex)?.setDrawingLocked(id, locked); setSelection(s => (s ? { ...s, locked } : s)) },
311 + onDuplicate: id => { const nid = view(selection.chartIndex)?.duplicateDrawing(id); if (nid) setSelection(s => (s ? { ...s, id: nid, locked: false } : s)) },
312 + onDelete: id => { view(selection.chartIndex)?.deleteDrawing(id); setSelection(null) },
313 + }
314 +
315 + // ---- context menu actions -----------------------------------------------------------------------------------------
316 + const onCtxAction = useCallback((id, menu) => {
317 + const v = viewRefs.current[menu.chartIndex]
318 + if (!v) return
319 + if (id === 'reset') v.resetView()
320 + else if (id === 'hline') v.addHLine(menu.price)
321 + else if (id === 'alert') v.addPriceLine(menu.price)
322 + else if (id === 'copy') { if (menu.price == null) return; const d = v.decimals(); const text = menu.price.toFixed(typeof d === 'number' ? d : 4); navigator.clipboard?.writeText(text).catch(() => {}) }
323 + else if (id === 'png') a.screenshot()
324 + else if (id === 'table') setTable(true)
325 + else if (id === 'settings') { if (isMobile) setSheet('settings'); else document.querySelector('[data-testid="ch-settings-menu"]')?.click() }
326 + }, [a, isMobile])
379 327
380 328 // ---- keyboard ------------------------------------------------------------------------------------------------------
381 329 useEffect(() => {
382 330 const onKey = e => {
383 − const c = chartRef.current
384 − if (!c) return
331 + const v = viewRefs.current[pageRef.current.active]
385 332 const meta = e.metaKey || e.ctrlKey
386 333 if ((e.key === '/' && !e.altKey) && (meta || !isTyping(e))) { e.preventDefault(); e.stopImmediatePropagation(); symbolRef.current?.focus(); return }
334 + if (meta && e.key.toLowerCase() === 'i' && !e.shiftKey) { e.preventDefault(); e.stopImmediatePropagation(); setLibrary(o => !o); return }
335 + if (meta && e.key.toLowerCase() === 'k' && !e.shiftKey) { e.preventDefault(); e.stopImmediatePropagation(); if (isMobile) setSheet('templates'); else document.querySelector('[data-testid="ch-tpl-menu"]')?.click(); return }
387 336 if (isTyping(e)) return
388 − if (meta && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? c.redo() : c.undo(); return }
389 − if (meta && e.key.toLowerCase() === 'y') { e.preventDefault(); c.redo(); return }
337 + if (meta && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? v?.redo() : v?.undo(); return }
338 + if (meta && e.key.toLowerCase() === 'y') { e.preventDefault(); v?.redo(); return }
339 + if (meta && e.key.toLowerCase() === 'd') { if (selection) { e.preventDefault(); propsApi.onDuplicate(selection.id) } return }
390 340 if (meta) return
391 − if (e.key === 'Escape') { if (tool) { setToolAction(null) } return }
392 − if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); c.deleteSelectedDrawing(); return }
341 + if (e.altKey && LAYOUT_KEYS[Number(e.key) - 1]) { e.preventDefault(); setLayoutInternal(LAYOUT_KEYS[Number(e.key) - 1]); return }
342 + if (e.altKey) return
343 + if (e.key === '?' || (e.shiftKey && e.key === '/')) { e.preventDefault(); setShortcuts(o => !o); return }
344 + if (e.shiftKey && e.key.toLowerCase() === 'r') { e.preventDefault(); a.toggleReplay(); return }
345 + if (e.key === 'Escape') { if (ctxMenu) setCtxMenu(null); else if (replay.jumping) setReplay(r => ({ ...r, jumping: false })); else if (tool) setToolAction(null); else if (selection) setSelection(null); return }
346 + if (e.key === 'Tab' && count > 1 && rootRef.current?.contains(document.activeElement) && document.activeElement?.closest('.ch-view')) { e.preventDefault(); setPage(p => ({ ...p, active: (p.active + (e.shiftKey ? count - 1 : 1)) % count })); viewRefs.current[(active + (e.shiftKey ? count - 1 : 1)) % count]?.focus(); return }
347 + if (replay.on) {
348 + if (e.key === ' ') { e.preventDefault(); replayApi.toggle(); return }
349 + if (e.key === 'ArrowLeft') { e.preventDefault(); replayApi.step(-1); return }
350 + if (e.key === 'ArrowRight') { e.preventDefault(); replayApi.step(1); return }
351 + }
352 + if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); if (selection) propsApi.onDelete(selection.id); else v?.deleteSelected(); return }
393 353 const k = e.key.toLowerCase()
394 354 if (TOOL_KEYS[k] !== undefined && !e.shiftKey) { e.preventDefault(); setToolAction(tool === TOOL_KEYS[k] ? null : TOOL_KEYS[k]) }
395 355 }
396 356 window.addEventListener('keydown', onKey, true)
397 357 return () => window.removeEventListener('keydown', onKey, true)
398 − }, [tool, setToolAction])
358 + }, [tool, setToolAction, selection, ctxMenu, replay.on, replay.jumping, count, active, a, replayApi, setLayoutInternal, isMobile]) // eslint-disable-line react-hooks/exhaustive-deps
399 359
400 360 // ---- render --------------------------------------------------------------------------------------------------------
401 − const err = status.error
402 − const chartData = chartRef.current?.getData() || []
403 361 const showDrawbar = isMobile ? drawbarOpen : prefs.showDrawingBar !== false
404 − const stale = status.phase === 'loading' && status.count > 0
362 + const hasDrawings = Object.values(drawingsCount).some(n => n > 0)
363 + const activeView = viewRefs.current[active]
364 + const chartData = activeView?.getData() || []
365 + const activeRange = activeView?.getRange() || null
366 + const settingsInd = settingsFor ? charts[settingsFor.chartIndex]?.indicators.find(i => i.id === settingsFor.id) : null
367 + const settingsColors = settingsFor ? (viewRefs.current[settingsFor.chartIndex]?.chart?.()?.getIndicators?.().find(i => i.id === settingsFor.id)?.plotColors || null) : null
368 + const replayView = replay.on ? viewRefs.current[replay.chartIndex] : null
369 + const replayTotal = replay.on ? replayTotals.current[replay.chartIndex] || 0 : 0
370 + const replayBarData = replay.on ? (replayView?.getData() || []) : []
371 + const replayBar = replay.on ? replayBarData[Math.min(replayBarData.length - 1, replay.index)] || null : null
405 372
406 373 return (
407 − <main className={`ch-page ${fullscreen ? 'is-fullscreen' : ''}`} ref={rootRef} data-testid="ch-page">
408 − <h1 className="sr-only">Charts — {state.label} {TF_LABEL[state.tf]}</h1>
409 − <Toolbar state={state} prefs={prefs} a={a} apiKey={apiKey} authenticated={authenticated} fullscreen={fullscreen} table={table} drawbarOpen={drawbarOpen} symbolRef={symbolRef} isMobile={isMobile} />
374 + <main className={`ch-page ${fullscreen ? 'is-fullscreen' : ''}`} ref={rootRef} data-testid="ch-page" data-layout={page.layout}>
375 + <h1 className="sr-only">Charts — {cs.label} {TF_LABEL[cs.tf]}</h1>
376 + <Toolbar cs={cs} page={page} prefs={prefs} a={a} fullscreen={fullscreen} table={table} drawbarOpen={drawbarOpen} symbolRef={symbolRef} isMobile={isMobile} replayOn={replay.on} templates={templates} favorites={favorites} />
410 377 <div className="ch-body">
411 − {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} />}
412 − <div className={`ch-stage ${stale ? 'is-stale' : ''}`}>
413 − <div className="ch-canvas" ref={containerRef} data-testid="ch-canvas" aria-label={`${state.label} ${TF_LABEL[state.tf]} price chart`} role="img" />
414 − {status.phase !== 'error' && (
415 − <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: [] }}
416 − onUpdateIndicator={a.updateIndicator} onRemoveIndicator={a.removeIndicator} onRemoveCompare={a.removeCompare} compact={isMobile} />
417 − )}
418 − {status.phase === 'loading' && status.count === 0 && (
419 − <div className="ch-skeleton" aria-busy="true" aria-label="Loading chart" data-testid="ch-skeleton">
420 − <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>
421 − <Skeleton width="40%" height="12px" />
422 − </div>
423 − )}
424 − {status.phase === 'error' && err && (
425 − <div className="ch-overlay" data-testid="ch-error">
426 − {err.kind === 'rate_limit' ? (
427 − <ErrorState status={429} code={err.code} retryUntil={err.retryUntil} authenticated={authenticated} message={barsErrorMessage(err)} onRetry={a.retry} />
428 − ) : err.kind === 'not_found' ? (
429 − <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.`}
430 − actions={<Button size="sm" variant="ghost" onClick={() => symbolRef.current?.focus()}>Search another symbol</Button>}>
431 − {suggestions.length > 0 && (
432 − <div className="ch-suggest" data-testid="ch-suggestions">
433 − <span className="muted small">Did you mean</span>
434 − {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>)}
435 − </div>
436 − )}
437 − </ErrorState>
438 − ) : (
439 − <ErrorState status={err.status || 0} code={err.code} message={barsErrorMessage(err)} onRetry={a.retry} />
440 − )}
441 − </div>
442 − )}
378 + {showDrawbar && <DrawingBar tool={tool} onTool={setToolAction} onUndo={() => activeView?.undo()} onRedo={() => activeView?.redo()} onClear={() => viewRefs.current.forEach(v => v?.clearDrawings())} magnet={prefs.magnet} onMagnet={v => a.setPref('magnet', v)} lock={!!prefs.drawLock} onLock={v => a.setPref('drawLock', v)} hiddenAll={hiddenAll} onHideAll={setHiddenAll} lockedAll={lockedAll} onLockAll={setLockedAll} hasDrawings={hasDrawings} lastTools={lastTools} onLastTool={(g, t) => setLastTools(m => ({ ...m, [g]: t }))} horizontal={isMobile} />}
379 + <div className={`ch-stage ch-layout-${page.layout}`} data-testid="ch-stage">
380 + {selection && !isMobile && <DrawingProps selection={selection} {...propsApi} />}
381 + <div className="ch-grid" data-testid="ch-grid">
382 + {charts.slice(0, count).map((c, i) => (
383 + <ChartView key={i} ref={el => { viewRefs.current[i] = el }} index={i} cs={c} onChange={patch => onChartChange(i, patch)} prefs={prefs} theme={theme} active={i === active && count > 1} onActivate={onActivate} isMobile={isMobile}
384 + tool={tool} onToolChange={onToolChange} onStatus={onStatus} onSelection={onSelection} onContextMenu={onContextMenu} onCrosshair={onCrosshair} onRange={onRange} onReplayJump={onReplayJump}
385 + replay={replay.on && replay.chartIndex === i ? replay : null} hiddenAll={hiddenAll} lockedAll={lockedAll} drawLock={!!prefs.drawLock} onFirstCatalog={onFirstCatalog} debug={debug} reloadKey={reloadKeys[i] || 0} />
386 + ))}
387 + </div>
388 + {replay.on && !isMobile && <ReplayBar replay={replay} total={replayTotal} bar={replayBar} tf={charts[replay.chartIndex]?.tf} tz={charts[replay.chartIndex]?.asset === 'contract' ? 'UTC' : 'ET'} onToggle={replayApi.toggle} onStep={replayApi.step} onSpeed={replayApi.speed} onJumpMode={replayApi.jumpMode} onSeek={replayApi.seek} onExit={replayApi.exit} />}
443 389 </div>
444 − {table && <BarsTable bars={chartData} range={range} state={state} decimals={decimals} tz={tz} onClose={() => setTable(false)} />}
390 + {table && <BarsTable bars={chartData} range={activeRange} state={cs} decimals={activeView?.decimals() ?? 2} tz={cs.asset === 'contract' ? 'UTC' : 'ET'} onClose={() => setTable(false)} />}
445 391 </div>
446 − <StatusBar status={status} state={state} tz={tz} authenticated={authenticated} compact={isMobile} />
392 + {replay.on && isMobile && <ReplayBar compact replay={replay} total={replayTotal} bar={replayBar} tf={charts[replay.chartIndex]?.tf} tz={charts[replay.chartIndex]?.asset === 'contract' ? 'UTC' : 'ET'} onToggle={replayApi.toggle} onStep={replayApi.step} onSpeed={replayApi.speed} onJumpMode={replayApi.jumpMode} onSeek={replayApi.seek} onExit={replayApi.exit} />}
393 + <StatusBar status={status} cs={cs} tz={cs.asset === 'contract' ? 'UTC' : 'ET'} compact={isMobile} onRetryOlder={() => activeView?.retryOlder()} layoutLabel={count > 1 ? `${LAYOUTS[page.layout].label} · chart ${active + 1} active` : null} />
447 394
395 + <ContextMenu menu={ctxMenu} decimals={activeView?.decimals() ?? 2} onClose={() => setCtxMenu(null)} onAction={onCtxAction} />
396 + <IndicatorLibrary open={library} onClose={() => setLibrary(false)} active={cs.indicators} onAdd={a.addIndicator} onRemove={a.removeIndicator} onToggleHidden={a.toggleHidden} onMove={a.moveIndicator} onOpenSettings={id => { setLibrary(false); setSettingsFor({ chartIndex: active, id }) }} favorites={favorites} onToggleFavorite={a.toggleFavorite} theme={theme} key={`lib-${catalogTick}`} />
397 + <IndicatorSettings open={!!settingsInd} ind={settingsInd} plotColors={settingsColors} theme={theme} onClose={() => setSettingsFor(null)} onChange={(id, patch) => patchChart(settingsFor.chartIndex, c => ({ indicators: c.indicators.map(i => (i.id === id ? { ...i, ...patch } : i)) }))} onRemove={id => patchChart(settingsFor.chartIndex, c => ({ indicators: c.indicators.filter(i => i.id !== id) }))} />
398 + <ShortcutsDialog open={shortcuts} onClose={() => setShortcuts(false)} />
399 +
400 + {isMobile && selection && (
401 + <BottomSheet open onClose={() => setSelection(null)} title="Drawing properties" testId="ch-props-sheet"><DrawingProps selection={selection} {...propsApi} sheet /></BottomSheet>
402 + )}
448 403 <BottomSheet open={sheet === 'tools'} onClose={() => setSheet(null)} title="Chart tools">
449 404 <div className="ch-sheet-grid">
450 − <button type="button" className="ch-sheet-btn" onClick={() => setSheet('type')}>Series type<span className="muted">{state.type}</span></button>
451 − <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>
452 − <button type="button" className="ch-sheet-btn" onClick={() => setSheet('compare')}>Compare<span className="muted">{state.compares.length || 'none'}</span></button>
453 − {ADJ_ASSETS.has(state.asset) && <button type="button" className="ch-sheet-btn" onClick={() => setSheet('adjustment')}>Adjustment<span className="muted">{state.adjustment || 'default'}</span></button>}
454 − <button type="button" className="ch-sheet-btn" onClick={() => setSheet('scale')}>Price scale<span className="muted">{state.scale}</span></button>
405 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('type')}>Series type<span className="muted">{cs.type}</span></button>
406 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('indicators')} data-testid="ch-sheet-indicators">Indicators<span className="muted">{cs.indicators.length || 'none'}</span></button>
407 + <button type="button" className="ch-sheet-btn" onClick={a.openLibrary} data-testid="ch-sheet-library">Indicator library<span className="muted">categories · favourites</span></button>
408 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('compare')}>Compare<span className="muted">{cs.compares.length || 'none'}</span></button>
409 + {ADJ_ASSETS.has(cs.asset) && <button type="button" className="ch-sheet-btn" onClick={() => setSheet('adjustment')}>Adjustment<span className="muted">{cs.adjustment || 'default'}</span></button>}
410 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('scale')}>Price scale<span className="muted">{cs.scale}</span></button>
411 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('layout')} data-testid="ch-sheet-layout">Layout<span className="muted">{LAYOUTS[page.layout]?.label}</span></button>
412 + <button type="button" className="ch-sheet-btn" onClick={() => setSheet('templates')} data-testid="ch-sheet-templates">Templates<span className="muted">{templates.length || 'none'}</span></button>
413 + <button type="button" className="ch-sheet-btn" onClick={() => { a.toggleReplay(); setSheet(null) }} data-testid="ch-sheet-replay"><span className="row" style={{ gap: 8 }}><ReplayIcon /> Bar replay</span><span className="muted">{replay.on ? 'on' : 'off'}</span></button>
455 414 <button type="button" className="ch-sheet-btn" onClick={() => setSheet('settings')}>Settings<span className="muted">volume, colours…</span></button>
456 415 <button type="button" className="ch-sheet-btn" onClick={() => { setTable(true); setSheet(null) }}><TableIcon /> Table of visible bars</button>
457 416 <button type="button" className="ch-sheet-btn" onClick={() => { a.screenshot(); setSheet(null) }}><CameraIcon /> Screenshot (PNG)</button>
458 417 <button type="button" className="ch-sheet-btn" onClick={() => { a.toggleFullscreen(); setSheet(null) }}><ExpandIcon /> Fullscreen</button>
418 + <button type="button" className="ch-sheet-btn" onClick={a.openShortcuts}><KeyboardIcon /> Keyboard shortcuts</button>
459 419 <CopyButton text={() => window.location.href} label="Copy share link" copiedLabel="Link copied" size="md" variant="secondary" className="ch-sheet-btn" />
460 420 </div>
461 421 </BottomSheet>
462 − <BottomSheet open={sheet === 'type'} onClose={() => setSheet(null)} title="Series type"><SeriesTypePanel value={state.type} onChange={a.setType} /></BottomSheet>
463 − <BottomSheet open={sheet === 'indicators'} onClose={() => setSheet(null)} title="Indicators"><IndicatorsPanel onAdd={a.addIndicator} active={state.indicators.map(i => i.type)} /></BottomSheet>
464 − <BottomSheet open={sheet === 'compare'} onClose={() => setSheet(null)} title="Compare"><ComparePanel compares={state.compares} onAdd={a.addCompare} onRemove={a.removeCompare} apiKey={apiKey} /></BottomSheet>
465 − <BottomSheet open={sheet === 'adjustment'} onClose={() => setSheet(null)} title="Adjustment"><AdjustmentPanel asset={state.asset} value={state.adjustment} onChange={a.setAdjustment} /></BottomSheet>
466 − <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>
467 − <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>
422 + <BottomSheet open={sheet === 'type'} onClose={() => setSheet(null)} title="Series type"><SeriesTypePanel value={cs.type} onChange={a.setType} /></BottomSheet>
423 + <BottomSheet open={sheet === 'indicators'} onClose={() => setSheet(null)} title="Indicators"><IndicatorsPanel onAdd={a.addIndicator} active={cs.indicators.map(i => i.type)} favorites={favorites} onOpenLibrary={a.openLibrary} /></BottomSheet>
424 + <BottomSheet open={sheet === 'compare'} onClose={() => setSheet(null)} title="Compare"><ComparePanel compares={cs.compares} onAdd={a.addCompare} onRemove={a.removeCompare} /></BottomSheet>
425 + <BottomSheet open={sheet === 'adjustment'} onClose={() => setSheet(null)} title="Adjustment"><AdjustmentPanel asset={cs.asset} value={cs.adjustment} onChange={a.setAdjustment} /></BottomSheet>
426 + <BottomSheet open={sheet === 'scale'} onClose={() => setSheet(null)} title="Price scale"><ScalePanel scale={cs.compares.length ? 'percent' : cs.scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={cs.compares.length > 0} /></BottomSheet>
427 + <BottomSheet open={sheet === 'layout'} onClose={() => setSheet(null)} title="Layout"><LayoutPanel value={page.layout} onChange={a.setLayout} /></BottomSheet>
428 + <BottomSheet open={sheet === 'templates'} onClose={() => setSheet(null)} title="Templates"><TemplatesPanel templates={templates} onApply={a.applyTemplate} onSave={a.saveTemplate} onDelete={a.deleteTemplate} onSetDefault={a.setDefaultTemplate} onExport={a.exportTemplates} onImport={a.importTemplates} /></BottomSheet>
429 + <BottomSheet open={sheet === 'settings'} onClose={() => setSheet(null)} title="Settings"><SettingsPanel prefs={prefs} onPrefs={setPrefs} volume={cs.volume} onVolume={a.setVolume} multi={count > 1} /></BottomSheet>
468 430 </main>
469 431 )
470 432 }
471 −
472 −const ADJ_ASSETS = new Set(['stock', 'etf', 'futures'])
added hfmarketdata/web/src/pages/charts/ContextMenu.jsx +53 −0
@@ -0,0 +1,53 @@
1 +// Right-click menu on the chart: reset view, horizontal line at the price, visual alert (price line), copy price,
2 +// screenshot, table, settings. Positioned at the pointer, kept inside the viewport, keyboard navigable.
3 +import React, { useEffect, useRef } from 'react'
4 +import { formatPrice } from '../../charts/data/session.js'
5 +import { BellIcon, CameraIcon, CopyIcon, GearIcon, HLineIcon, ResetIcon, TableIcon } from './icons.jsx'
6 +
7 +export default function ContextMenu({ menu, decimals, onClose, onAction }) {
8 + const ref = useRef(null)
9 + useEffect(() => {
10 + if (!menu) return undefined
11 + const onDoc = e => { if (!ref.current?.contains(e.target)) onClose() }
12 + const onKey = e => {
13 + if (e.key === 'Escape') { e.stopPropagation(); onClose(); return }
14 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
15 + e.preventDefault()
16 + const items = [...ref.current.querySelectorAll('[role="menuitem"]:not([disabled])')]
17 + const i = items.indexOf(document.activeElement)
18 + items[e.key === 'ArrowDown' ? (i + 1) % items.length : (i - 1 + items.length) % items.length]?.focus()
19 + }
20 + }
21 + document.addEventListener('pointerdown', onDoc)
22 + document.addEventListener('keydown', onKey, true)
23 + setTimeout(() => ref.current?.querySelector('[role="menuitem"]')?.focus(), 0)
24 + // keep inside the viewport
25 + const el = ref.current
26 + if (el) {
27 + const r = el.getBoundingClientRect()
28 + if (r.right > window.innerWidth - 8) el.style.left = `${Math.max(8, menu.x - r.width)}px`
29 + if (r.bottom > window.innerHeight - 8) el.style.top = `${Math.max(8, menu.y - r.height)}px`
30 + }
31 + return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true) }
32 + }, [menu, onClose])
33 + if (!menu) return null
34 + const price = menu.price
35 + const items = [
36 + { id: 'reset', label: 'Reset view', Icon: ResetIcon, hint: 'Dbl-click' },
37 + { id: 'hline', label: price != null ? `Horizontal line at ${formatPrice(price, decimals)}` : 'Horizontal line', Icon: HLineIcon, disabled: price == null },
38 + { id: 'alert', label: price != null ? `Alert line at ${formatPrice(price, decimals)}` : 'Alert line', Icon: BellIcon, disabled: price == null },
39 + { id: 'copy', label: price != null ? `Copy price ${formatPrice(price, decimals)}` : 'Copy price', Icon: CopyIcon, disabled: price == null },
40 + { id: 'png', label: 'Screenshot (PNG)', Icon: CameraIcon },
41 + { id: 'table', label: 'Table of visible bars', Icon: TableIcon },
42 + { id: 'settings', label: 'Chart settings…', Icon: GearIcon },
43 + ]
44 + return (
45 + <div ref={ref} className="ch-ctx" role="menu" aria-label="Chart menu" style={{ left: menu.x, top: menu.y }} data-testid="ch-ctx">
46 + {items.map(({ id, label, Icon, hint, disabled }) => (
47 + <button key={id} type="button" role="menuitem" className="ch-item" disabled={disabled} onClick={() => { onAction(id, menu); onClose() }} data-testid={`ch-ctx-${id}`}>
48 + <span className="ch-item-icon"><Icon /></span><span className="ch-item-body">{label}</span>{hint && <span className="ch-item-hint">{hint}</span>}
49 + </button>
50 + ))}
51 + </div>
52 + )
53 +}
added hfmarketdata/web/src/pages/charts/Dialog.jsx +45 −0
@@ -0,0 +1,45 @@
1 +// Modal dialog of the charts page: centred panel on desktop, bottom sheet under 640 px (same component, same
2 +// children). Focus is moved inside on open and restored on close; Escape / backdrop close it; body scroll locked.
3 +import React, { useEffect, useRef } from 'react'
4 +import { CloseIcon } from '../../components/Icons.jsx'
5 +import useMediaQuery from '../../components/useMediaQuery.js'
6 +
7 +export default function Dialog({ open, onClose, title, children, testId = 'ch-dialog', size = 'md', footer, className = '', initialFocus }) {
8 + const ref = useRef(null)
9 + const prevFocus = useRef(null)
10 + const isMobile = useMediaQuery('(max-width: 640px)')
11 + useEffect(() => {
12 + if (!open) return undefined
13 + prevFocus.current = document.activeElement
14 + const onKey = e => {
15 + if (e.key === 'Escape') { e.stopPropagation(); onClose() }
16 + if (e.key === 'Tab' && ref.current) { // focus trap
17 + const f = [...ref.current.querySelectorAll('button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(el => el.offsetParent !== null)
18 + if (!f.length) return
19 + const first = f[0], last = f[f.length - 1]
20 + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
21 + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
22 + }
23 + }
24 + document.addEventListener('keydown', onKey, true)
25 + const prev = document.body.style.overflow
26 + document.body.style.overflow = 'hidden'
27 + const t = setTimeout(() => { (initialFocus?.current || ref.current?.querySelector('input, [role="tab"][aria-selected="true"], button:not(.ch-dialog-close), [tabindex]'))?.focus() }, 0)
28 + return () => { document.removeEventListener('keydown', onKey, true); document.body.style.overflow = prev; clearTimeout(t); prevFocus.current?.focus?.() }
29 + }, [open, onClose, initialFocus])
30 + if (!open) return null
31 + return (
32 + <>
33 + <div className="ch-sheet-backdrop" onClick={onClose} data-testid={`${testId}-backdrop`} />
34 + <div className={`${isMobile ? 'ch-sheet' : `ch-dialog is-${size}`} ${className}`} role="dialog" aria-modal="true" aria-label={title} ref={ref} data-testid={testId}>
35 + <div className={isMobile ? 'ch-sheet-head' : 'ch-dialog-head'}>
36 + {isMobile && <span className="ch-sheet-grip" aria-hidden="true" />}
37 + <strong>{title}</strong>
38 + <button type="button" className="icon-btn ch-dialog-close" aria-label="Close" onClick={onClose}><CloseIcon /></button>
39 + </div>
40 + <div className={isMobile ? 'ch-sheet-body' : 'ch-dialog-body'}>{children}</div>
41 + {footer && <div className="ch-dialog-foot">{footer}</div>}
42 + </div>
43 + </>
44 + )
45 +}
modified hfmarketdata/web/src/pages/charts/DrawingBar.jsx +73 −28
@@ -1,38 +1,83 @@
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'
1 +// Drawing toolbar with flyouts (like a professional terminal): one button per group showing the group's last used
2 +// tool — click activates it, the chevron (or a right-click / long press) opens the flyout with every tool of the
3 +// group. Below: magnet, "stay in drawing mode" lock, hide all, lock all, undo / redo, remove all. Under 640 px the
4 +// bar is horizontal and the flyouts open as bottom sheets (rendered by the parent through `onOpenGroup`).
5 +import React, { useEffect, useRef, useState } from 'react'
6 +import { availableGroups, toolGroup } from './drawtools.js'
7 +import BottomSheet from './BottomSheet.jsx'
8 +import { ChevronDownIcon } from '../../components/Icons.jsx'
9 +import { CursorIcon, EyeIcon, EyeOffIcon, LockIcon, MagnetIcon, RedoIcon, TOOL_ICONS, TrashIcon, UndoIcon, UnlockIcon, TargetIcon } from './icons.jsx'
5 10
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]))
11 +export { TOOL_KEYS } from './drawtools.js'
21 12
22 −export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magnet, onMagnet, hasDrawings, horizontal = false }) {
13 +function Flyout({ group, tool, onPick, onClose, horizontal }) {
14 + const ref = useRef(null)
15 + useEffect(() => {
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 + setTimeout(() => ref.current?.querySelector('[aria-pressed="true"], button')?.focus(), 0)
21 + return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true) }
22 + }, [onClose])
23 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 />
24 + <div ref={ref} className={`ch-flyout ${horizontal ? 'is-below' : ''}`} role="menu" aria-label={group.label} data-testid={`ch-flyout-${group.id}`}>
25 + <div className="ch-flyout-title">{group.label}</div>
26 + {group.tools.map(t => { const Icon = TOOL_ICONS[t.id] || CursorIcon; return (
27 + <button key={t.id} type="button" role="menuitemradio" aria-checked={tool === t.id} aria-pressed={tool === t.id} className="ch-flyout-item" data-tool={t.id} onClick={() => onPick(t.id)}>
28 + <Icon /><span>{t.label}</span>{t.key && <kbd>{t.key}</kbd>}
29 29 </button>
30 − ))}
30 + ) })}
31 + </div>
32 + )
33 +}
34 +
35 +export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magnet, onMagnet, lock, onLock, hiddenAll, onHideAll, lockedAll, onLockAll, hasDrawings, lastTools = {}, onLastTool, horizontal = false }) {
36 + const groups = availableGroups()
37 + const [open, setOpen] = useState(null)
38 + const current = toolGroup(tool)
39 + const pick = (g, id) => { onLastTool?.(g.id, id); onTool(tool === id ? null : id); setOpen(null) }
40 + const lastOf = g => g.tools.find(t => t.id === lastTools[g.id]) || g.tools[0]
41 + const longPress = useRef(0)
42 +
43 + return (
44 + <div className={`ch-drawbar ${horizontal ? 'is-horizontal' : ''}`} role="toolbar" aria-label="Drawing tools" aria-orientation={horizontal ? 'horizontal' : 'vertical'} data-testid="ch-drawbar">
45 + <button type="button" className="ch-tool" aria-pressed={tool === null} aria-label="Cursor" title="Cursor (Esc)" data-tool="cursor" onClick={() => onTool(null)}><CursorIcon /></button>
46 + {groups.map(g => {
47 + const last = lastOf(g)
48 + const Icon = TOOL_ICONS[last.id] || CursorIcon
49 + const active = current === g.id
50 + const ActiveIcon = active ? (TOOL_ICONS[tool] || Icon) : Icon
51 + const label = active ? g.tools.find(t => t.id === tool)?.label || last.label : last.label
52 + return (
53 + <div key={g.id} className={`ch-toolgroup ${open === g.id ? 'is-open' : ''}`} data-group={g.id}>
54 + <button type="button" className="ch-tool" aria-pressed={active} aria-label={`${label} (${g.label})`} title={`${label}${last.key ? ` (${last.key})` : ''} — right-click or ▸ for all ${g.label.toLowerCase()} tools`} data-tool={active ? tool : last.id} data-testid={`ch-group-${g.id}`}
55 + onClick={() => { if (Date.now() - longPress.current < 400) return; if (active) { onTool(null); return } onLastTool?.(g.id, last.id); onTool(last.id) }}
56 + onContextMenu={e => { e.preventDefault(); setOpen(o => (o === g.id ? null : g.id)) }}
57 + onPointerDown={e => { if (e.pointerType === 'touch') { longPress.current = 0; const t = setTimeout(() => { longPress.current = Date.now(); setOpen(g.id) }, 450); e.currentTarget.addEventListener('pointerup', () => clearTimeout(t), { once: true }); e.currentTarget.addEventListener('pointerleave', () => clearTimeout(t), { once: true }) } }}>
58 + <ActiveIcon />
59 + </button>
60 + <button type="button" className="ch-tool-more" aria-label={`All ${g.label.toLowerCase()} tools`} aria-haspopup="menu" aria-expanded={open === g.id} onClick={() => setOpen(o => (o === g.id ? null : g.id))} data-testid={`ch-group-more-${g.id}`}><ChevronDownIcon /></button>
61 + {open === g.id && !horizontal && <Flyout group={g} tool={tool} onPick={id => pick(g, id)} onClose={() => setOpen(null)} />}
62 + {open === g.id && horizontal && (
63 + <BottomSheet open onClose={() => setOpen(null)} title={g.label} testId={`ch-flyout-${g.id}`}>
64 + <div className="ch-sheet-grid">
65 + {g.tools.map(t => { const TIcon = TOOL_ICONS[t.id] || CursorIcon; return <button key={t.id} type="button" className="ch-sheet-btn" aria-pressed={tool === t.id} data-tool={t.id} onClick={() => pick(g, t.id)}><span className="row" style={{ gap: 8 }}><TIcon />{t.label}</span>{t.key && <kbd className="muted">{t.key}</kbd>}</button> })}
66 + </div>
67 + </BottomSheet>
68 + )}
69 + </div>
70 + )
71 + })}
72 + <span className="ch-drawbar-sep" aria-hidden="true" />
73 + <button type="button" className="ch-tool" aria-pressed={magnet} aria-label="Magnet mode" title="Magnet: snap to OHLC" onClick={() => onMagnet(!magnet)} data-testid="ch-draw-magnet"><MagnetIcon /></button>
74 + <button type="button" className="ch-tool" aria-pressed={lock} aria-label="Stay in drawing mode" title="Stay in drawing mode after each drawing" onClick={() => onLock(!lock)} data-testid="ch-draw-lock"><TargetIcon /></button>
75 + <button type="button" className="ch-tool" aria-pressed={hiddenAll} aria-label={hiddenAll ? 'Show all drawings' : 'Hide all drawings'} title={hiddenAll ? 'Show all drawings' : 'Hide all drawings'} onClick={() => onHideAll(!hiddenAll)} disabled={!hasDrawings && !hiddenAll} data-testid="ch-draw-hide">{hiddenAll ? <EyeOffIcon /> : <EyeIcon />}</button>
76 + <button type="button" className="ch-tool" aria-pressed={lockedAll} aria-label={lockedAll ? 'Unlock all drawings' : 'Lock all drawings'} title={lockedAll ? 'Unlock all drawings' : 'Lock all drawings'} onClick={() => onLockAll(!lockedAll)} disabled={!hasDrawings} data-testid="ch-draw-lockall">{lockedAll ? <LockIcon /> : <UnlockIcon />}</button>
31 77 <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 78 <button type="button" className="ch-tool" aria-label="Undo" title="Undo (Ctrl+Z)" onClick={onUndo}><UndoIcon /></button>
34 79 <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>
80 + <button type="button" className="ch-tool ch-tool-danger" aria-label="Remove all drawings" title="Remove all drawings" onClick={onClear} disabled={!hasDrawings} data-testid="ch-draw-clear"><TrashIcon /></button>
36 81 </div>
37 82 )
38 83 }
added hfmarketdata/web/src/pages/charts/DrawingProps.jsx +44 −0
@@ -0,0 +1,44 @@
1 +// Floating properties bar shown when a drawing is selected (engine v2 `drawingSelect` event): colour, line width,
2 +// line style, fill, text, lock, duplicate, delete. Under 640 px it is rendered as a bottom sheet by the parent.
3 +import React from 'react'
4 +import { Select } from '../../components/Field.jsx'
5 +import { DRAW_COLORS, LINE_STYLES, LINE_WIDTHS, toolDef } from './drawtools.js'
6 +import { CopyIcon, LockIcon, TrashIcon, UnlockIcon } from './icons.jsx'
7 +
8 +const FILLABLE = new Set(['rect', 'ellipse', 'triangle', 'channel', 'channel-parallel', 'gann-box', 'long-position', 'short-position', 'date-price-range', 'price-range', 'date-range', 'vertical-range', 'callout', 'price-label', 'fib', 'fib-extension'])
9 +const TEXTUAL = new Set(['text', 'callout', 'price-label', 'flag', 'arrow-marker', 'hline', 'hray', 'trendline', 'ray', 'extended', 'vline'])
10 +
11 +export default function DrawingProps({ selection, onStyle, onText, onLock, onDuplicate, onDelete, sheet = false }) {
12 + if (!selection) return null
13 + const { id, type, style = {}, text = '', locked = false } = selection
14 + const def = toolDef(type)
15 + const color = style.color || '#3987e5'
16 + const set = patch => onStyle(id, { ...style, ...patch })
17 + return (
18 + <div className={`ch-props ${sheet ? 'is-sheet' : ''}`} role="toolbar" aria-label={`${def?.label || type} properties`} data-testid="ch-props">
19 + <span className="ch-props-name">{def?.label || type}</span>
20 + <span className="ch-colors" role="radiogroup" aria-label="Colour">
21 + {DRAW_COLORS.map(c => <button key={c} type="button" role="radio" aria-checked={color.toLowerCase() === c} aria-label={c} className="ch-color" style={{ background: c }} onClick={() => set({ color: c })} />)}
22 + <input type="color" value={/^#[0-9a-f]{6}$/i.test(color) ? color : '#3987e5'} aria-label="Custom colour" onChange={e => set({ color: e.target.value })} />
23 + </span>
24 + <label className="ch-props-field"><span className="sr-only">Width</span>
25 + <Select value={style.width ?? style.lineWidth ?? 1} aria-label="Line width" onChange={e => set({ width: Number(e.target.value), lineWidth: Number(e.target.value) })}>{LINE_WIDTHS.map(w => <option key={w} value={w}>{w} px</option>)}</Select>
26 + </label>
27 + <label className="ch-props-field"><span className="sr-only">Line style</span>
28 + <Select value={style.style || (style.dash ? 'dashed' : 'solid')} aria-label="Line style" onChange={e => set({ style: e.target.value, dash: e.target.value === 'dashed' ? [6, 4] : e.target.value === 'dotted' ? [2, 3] : null })}>{LINE_STYLES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</Select>
29 + </label>
30 + {FILLABLE.has(type) && (
31 + <label className="ch-props-field ch-props-fill"><span>Fill</span>
32 + <input type="range" min="0" max="0.6" step="0.05" value={style.fillOpacity ?? style.fill ?? 0.12} aria-label="Fill opacity" onChange={e => set({ fillOpacity: Number(e.target.value), fill: Number(e.target.value) })} />
33 + </label>
34 + )}
35 + {TEXTUAL.has(type) && (
36 + <input className="input ch-props-text" type="text" value={text} placeholder="Label…" aria-label="Text" onChange={e => onText(id, e.target.value)} onKeyDown={e => e.stopPropagation()} data-testid="ch-props-text" />
37 + )}
38 + <span className="ch-props-spacer" />
39 + <button type="button" className="ch-tool" aria-pressed={locked} aria-label={locked ? 'Unlock drawing' : 'Lock drawing'} title={locked ? 'Unlock' : 'Lock'} onClick={() => onLock(id, !locked)}>{locked ? <LockIcon /> : <UnlockIcon />}</button>
40 + <button type="button" className="ch-tool" aria-label="Duplicate drawing" title="Duplicate" onClick={() => onDuplicate(id)}><CopyIcon /></button>
41 + <button type="button" className="ch-tool ch-tool-danger" aria-label="Delete drawing" title="Delete (Del)" onClick={() => onDelete(id)}><TrashIcon /></button>
42 + </div>
43 + )
44 +}
added hfmarketdata/web/src/pages/charts/IndicatorLibrary.jsx +79 −0
@@ -0,0 +1,79 @@
1 +// Indicator library (⌘I): searchable, category tabs (Favorites first), one-click add, favourites in localStorage,
2 +// short description per entry, and the list of active indicators with reorder / hide / settings / remove.
3 +import React, { useEffect, useMemo, useRef, useState } from 'react'
4 +import Dialog from './Dialog.jsx'
5 +import { Input } from '../../components/Field.jsx'
6 +import { CATEGORIES, catalog, indicatorShortLabel, searchCatalog } from './indicators.js'
7 +import { CloseIcon } from '../../components/Icons.jsx'
8 +import { DownIcon, EyeIcon, EyeOffIcon, GearIcon, StarIcon, UpIcon } from './icons.jsx'
9 +import { seriesColor } from './theme.js'
10 +
11 +export default function IndicatorLibrary({ open, onClose, active = [], onAdd, onRemove, onToggleHidden, onMove, onOpenSettings, favorites = [], onToggleFavorite, theme }) {
12 + const [q, setQ] = useState('')
13 + const [cat, setCat] = useState('All')
14 + const inputRef = useRef(null)
15 + useEffect(() => { if (open) { setQ(''); setCat(favorites.length ? 'Favorites' : 'All') } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps
16 + const all = useMemo(() => catalog(), [open]) // eslint-disable-line react-hooks/exhaustive-deps
17 + const cats = ['All', ...CATEGORIES.filter(c => c === 'Favorites' ? favorites.length > 0 : all.some(d => d.category === c))]
18 + const list = searchCatalog(q, q ? 'All' : cat, favorites)
19 + const counts = useMemo(() => { const m = {}; for (const i of active) m[i.type] = (m[i.type] || 0) + 1; return m }, [active])
20 +
21 + const onKey = e => {
22 + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
23 + const items = [...(e.currentTarget.querySelectorAll('[data-lib-item]') || [])]
24 + if (!items.length) return
25 + e.preventDefault()
26 + const i = items.indexOf(document.activeElement)
27 + const next = e.key === 'ArrowDown' ? items[Math.min(items.length - 1, i + 1)] : items[Math.max(0, i - 1)]
28 + next?.focus()
29 + }
30 +
31 + return (
32 + <Dialog open={open} onClose={onClose} title="Indicators" testId="ch-library" size="lg" initialFocus={inputRef}>
33 + <div className="ch-lib">
34 + <div className="ch-lib-main" onKeyDown={onKey}>
35 + <div className="ch-lib-search">
36 + <Input ref={inputRef} type="search" placeholder="Search indicators… (name, id, description)" value={q} onChange={e => setQ(e.target.value)} aria-label="Search indicators" data-testid="ch-lib-search" />
37 + </div>
38 + {!q && (
39 + <div className="ch-lib-tabs" role="tablist" aria-label="Categories">
40 + {cats.map(c => <button key={c} type="button" role="tab" aria-selected={cat === c} className="ch-lib-tab" onClick={() => setCat(c)} data-testid={`ch-lib-cat-${c.replace(/\W/g, '').toLowerCase()}`}>{c}{c === 'Favorites' ? ` (${favorites.length})` : ''}</button>)}
41 + </div>
42 + )}
43 + <ul className="ch-lib-list" role="list" data-testid="ch-lib-list">
44 + {list.map(d => (
45 + <li key={d.id} className="ch-lib-item">
46 + <button type="button" className="ch-lib-add" data-lib-item onClick={() => onAdd(d.id)} data-testid={`ch-lib-add-${d.id}`} aria-label={`Add ${d.name}`}>
47 + <span className="ch-lib-name">{d.name}{counts[d.id] ? <span className="ch-lib-count">×{counts[d.id]}</span> : null}</span>
48 + <span className="ch-lib-desc">{d.description || `${d.category} · ${d.pane === 'main' ? 'overlay' : 'own pane'}`}</span>
49 + </button>
50 + <span className="ch-lib-meta">{d.category}</span>
51 + <button type="button" className={`ch-lib-star ${favorites.includes(d.id) ? 'is-on' : ''}`} aria-pressed={favorites.includes(d.id)} aria-label={favorites.includes(d.id) ? `Remove ${d.name} from favourites` : `Add ${d.name} to favourites`} onClick={() => onToggleFavorite(d.id)} data-testid={`ch-lib-fav-${d.id}`}><StarIcon filled={favorites.includes(d.id)} /></button>
52 + </li>
53 + ))}
54 + {!list.length && <li className="ch-lib-empty muted small">No indicator matches “{q}”.</li>}
55 + </ul>
56 + </div>
57 + <aside className="ch-lib-active" aria-label="Active indicators">
58 + <div className="ch-lib-active-title">Active ({active.length})</div>
59 + {active.length === 0 && <p className="muted small" style={{ margin: 0 }}>Nothing yet — click an indicator to add it.</p>}
60 + <ul role="list" className="ch-lib-active-list" data-testid="ch-lib-active">
61 + {active.map((ind, i) => (
62 + <li key={ind.id} className={`ch-lib-active-row ${ind.hidden ? 'is-hidden' : ''}`} data-testid="ch-lib-active-row">
63 + <span className="ch-swatch" style={{ background: theme ? seriesColor(theme, ind.colorIndex ?? 0) : undefined }} aria-hidden="true" />
64 + <span className="ch-lib-active-name">{indicatorShortLabel(ind)}</span>
65 + <span className="ch-lib-active-actions">
66 + <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} up`} disabled={i === 0} onClick={() => onMove(ind.id, -1)}><UpIcon /></button>
67 + <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} down`} disabled={i === active.length - 1} onClick={() => onMove(ind.id, 1)}><DownIcon /></button>
68 + <button type="button" className="ch-legend-x" aria-pressed={!!ind.hidden} aria-label={ind.hidden ? `Show ${indicatorShortLabel(ind)}` : `Hide ${indicatorShortLabel(ind)}`} onClick={() => onToggleHidden(ind.id)}>{ind.hidden ? <EyeOffIcon /> : <EyeIcon />}</button>
69 + <button type="button" className="ch-legend-x" aria-label={`Settings of ${indicatorShortLabel(ind)}`} onClick={() => onOpenSettings(ind.id)}><GearIcon /></button>
70 + <button type="button" className="ch-legend-x" aria-label={`Remove ${indicatorShortLabel(ind)}`} onClick={() => onRemove(ind.id)}><CloseIcon /></button>
71 + </span>
72 + </li>
73 + ))}
74 + </ul>
75 + </aside>
76 + </div>
77 + </Dialog>
78 + )
79 +}
added hfmarketdata/web/src/pages/charts/IndicatorSettings.jsx +105 −0
@@ -0,0 +1,105 @@
1 +// Indicator settings dialog (double-click on the legend row or its ⚙): Inputs (form generated from the catalog
2 +// `inputs`), Style (colour / width / line style / visibility per plot, levels), Visibility (per timeframe).
3 +// Every change is applied live to the chart through `onChange`; "Reset defaults" restores the catalog values.
4 +import React, { useEffect, useState } from 'react'
5 +import Dialog from './Dialog.jsx'
6 +import { Input, Select } from '../../components/Field.jsx'
7 +import { TIMEFRAMES, TF_LABEL } from '../../charts/data/bars.js'
8 +import { defaultParams, indicatorDef, indicatorShortLabel } from './indicators.js'
9 +import { DRAW_COLORS, LINE_STYLES, LINE_WIDTHS } from './drawtools.js'
10 +import { seriesColor } from './theme.js'
11 +
12 +const TABS = [['inputs', 'Inputs'], ['style', 'Style'], ['visibility', 'Visibility']]
13 +
14 +function InputField({ inp, value, onChange }) {
15 + const id = `ch-inp-${inp.name}`
16 + if (inp.type === 'bool') return <label className="ch-set-row" htmlFor={id}><span>{inp.name}</span><input id={id} type="checkbox" checked={!!value} onChange={e => onChange(e.target.checked)} /></label>
17 + if (inp.type === 'select' || inp.type === 'source') {
18 + return <label className="ch-set-row" htmlFor={id}><span>{inp.name}</span><Select id={id} value={value ?? inp.default} onChange={e => onChange(e.target.value)}>{(inp.options || []).map(o => <option key={o} value={o}>{o}</option>)}</Select></label>
19 + }
20 + const step = inp.step ?? (inp.type === 'int' ? 1 : 0.1)
21 + return (
22 + <label className="ch-set-row" htmlFor={id}>
23 + <span>{inp.name}{inp.min != null && inp.max != null && Number.isFinite(inp.max) ? <small className="muted"> {inp.min}–{inp.max}</small> : null}</span>
24 + <span className="ch-set-num">
25 + <input type="range" min={inp.min ?? 1} max={Number.isFinite(inp.max) && inp.max <= 1000 ? inp.max : 500} step={step} value={Number(value ?? inp.default)} aria-label={`${inp.name} slider`} onChange={e => onChange(Number(e.target.value))} />
26 + <Input id={id} type="number" mono min={inp.min} max={Number.isFinite(inp.max) ? inp.max : undefined} step={step} value={value ?? inp.default} onChange={e => { const n = Number(e.target.value); if (Number.isFinite(n)) onChange(n) }} />
27 + </span>
28 + </label>
29 + )
30 +}
31 +
32 +export default function IndicatorSettings({ open, ind, plotColors, theme, onChange, onClose, onRemove }) {
33 + const [tab, setTab] = useState('inputs')
34 + useEffect(() => { if (open) setTab('inputs') }, [open, ind?.id])
35 + const def = ind ? indicatorDef(ind.type) : null
36 + if (!open || !ind || !def) return null
37 + const set = patch => onChange(ind.id, patch)
38 + const setParam = (k, v) => set({ params: { ...ind.params, [k]: v } })
39 + const plotStyle = key => ({ visible: true, ...(ind.plots?.[key] || {}) })
40 + const setPlot = (key, patch) => set({ plots: { ...(ind.plots || {}), [key]: { ...plotStyle(key), ...patch } } })
41 + const base = i => seriesColor(theme || { series: ['#3987e5'] }, (ind.colorIndex ?? 0) + i)
42 + const visible = tf => ind.visibility?.[tf] !== false
43 +
44 + return (
45 + <Dialog open={open} onClose={onClose} title={`${def.name} — ${indicatorShortLabel(ind)}`} testId="ch-ind-settings" size="md"
46 + footer={(
47 + <>
48 + <button type="button" className="btn btn-sm btn-ghost" onClick={() => set({ params: defaultParams(def), plots: undefined, levels: undefined, visibility: undefined })} data-testid="ch-ind-reset">Reset defaults</button>
49 + <span style={{ flex: 1 }} />
50 + {onRemove && <button type="button" className="btn btn-sm btn-ghost" onClick={() => { onRemove(ind.id); onClose() }}>Remove</button>}
51 + <button type="button" className="btn btn-sm btn-primary" onClick={onClose} data-testid="ch-ind-done">Done</button>
52 + </>
53 + )}>
54 + <div className="ch-tabs" role="tablist" aria-label="Indicator settings">
55 + {TABS.map(([id, label]) => <button key={id} type="button" role="tab" aria-selected={tab === id} className="ch-lib-tab" onClick={() => setTab(id)} data-testid={`ch-ind-tab-${id}`}>{label}</button>)}
56 + </div>
57 + {tab === 'inputs' && (
58 + <div className="ch-set-form" role="tabpanel" data-testid="ch-ind-inputs">
59 + {def.inputs.map(inp => <InputField key={inp.name} inp={inp} value={ind.params?.[inp.name]} onChange={v => setParam(inp.name, v)} />)}
60 + {!def.inputs.length && <p className="muted small" style={{ margin: 0 }}>This indicator has no inputs.</p>}
61 + {def.description && <p className="muted small ch-set-desc">{def.description}</p>}
62 + </div>
63 + )}
64 + {tab === 'style' && (
65 + <div className="ch-set-form" role="tabpanel" data-testid="ch-ind-style">
66 + {def.plots.map((p, i) => {
67 + const st = plotStyle(p.key)
68 + const color = st.color || plotColors?.[p.key] || base(i)
69 + return (
70 + <fieldset key={p.key} className="ch-set-plot">
71 + <legend className="mono">{p.key}<span className="muted small"> · {p.type}</span></legend>
72 + <label className="ch-set-row"><span>Visible</span><input type="checkbox" checked={st.visible !== false} onChange={e => setPlot(p.key, { visible: e.target.checked })} /></label>
73 + <div className="ch-set-row"><span>Colour</span>
74 + <span className="ch-colors" role="radiogroup" aria-label={`${p.key} colour`}>
75 + {DRAW_COLORS.map(c => <button key={c} type="button" role="radio" aria-checked={color.toLowerCase() === c} aria-label={c} className="ch-color" style={{ background: c }} onClick={() => setPlot(p.key, { color: c })} />)}
76 + <input type="color" value={/^#[0-9a-f]{6}$/i.test(color) ? color : '#3987e5'} aria-label={`${p.key} custom colour`} onChange={e => setPlot(p.key, { color: e.target.value })} />
77 + </span>
78 + </div>
79 + {p.type !== 'histogram' && (
80 + <>
81 + <label className="ch-set-row"><span>Width</span><Select value={st.lineWidth ?? p.lineWidth ?? 1} onChange={e => setPlot(p.key, { lineWidth: Number(e.target.value) })}>{LINE_WIDTHS.map(w => <option key={w} value={w}>{w} px</option>)}</Select></label>
82 + <label className="ch-set-row"><span>Style</span><Select value={st.style ?? p.style ?? 'solid'} onChange={e => setPlot(p.key, { style: e.target.value })}>{LINE_STYLES.map(([id, l]) => <option key={id} value={id}>{l}</option>)}</Select></label>
83 + </>
84 + )}
85 + </fieldset>
86 + )
87 + })}
88 + {def.levels && (
89 + <label className="ch-set-row"><span>Levels</span>
90 + <Input mono value={(ind.levels ?? def.levels).join(', ')} aria-label="Levels (comma separated)" onChange={e => { const nums = e.target.value.split(/[,\s]+/).map(Number).filter(Number.isFinite); set({ levels: nums }) }} />
91 + </label>
92 + )}
93 + </div>
94 + )}
95 + {tab === 'visibility' && (
96 + <div className="ch-set-form" role="tabpanel" data-testid="ch-ind-visibility">
97 + <p className="muted small" style={{ margin: '0 0 6px' }}>Show this indicator on these timeframes only:</p>
98 + {TIMEFRAMES.map(tf => (
99 + <label key={tf} className="ch-set-row"><span>{TF_LABEL[tf]}</span><input type="checkbox" checked={visible(tf)} onChange={e => set({ visibility: { ...(ind.visibility || {}), [tf]: e.target.checked } })} data-testid={`ch-ind-vis-${tf}`} /></label>
100 + ))}
101 + </div>
102 + )}
103 + </Dialog>
104 + )
105 +}
modified hfmarketdata/web/src/pages/charts/Legend.jsx +32 −48
@@ -1,74 +1,48 @@
1 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.
2 +// bar, volume, one row per indicator (swatch, label, values coloured per plot, hide / settings / remove buttons —
3 +// all keyboard reachable) and one "Compare" row per symbol. Two aria-live regions: the hovered bar (throttled) and
4 +// the description of the visible range after a pan / zoom (`describeVisible()` of the engine, or a page fallback).
4 5 import React, { useEffect, useRef, useState } from 'react'
5 6 import { CloseIcon } from '../../components/Icons.jsx'
6 7 import { TF_LABEL } from '../../charts/data/bars.js'
7 8 import { formatPrice, formatStampLabel, formatVolume } from '../../charts/data/session.js'
8 9 import { indicatorDef, indicatorShortLabel } from './indicators.js'
10 +import { EyeIcon, EyeOffIcon, GearIcon } from './icons.jsx'
9 11 import { seriesColor } from './theme.js'
10 12
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 13 const fmtVal = (v, d) => (v == null || !Number.isFinite(v) ? '—' : formatPrice(v, typeof d === 'number' ? Math.min(d, 4) : 'auto'))
40 14
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)
15 +export default function Legend({ cs, name, hover, lastBar, prevBar, indValues, cmpValues, plotColors, decimals, tz, theme, onOpenSettings, onToggleHidden, onRemoveIndicator, onRemoveCompare, compact = false, describe = '', replayLabel = null }) {
43 16 const bar = hover?.bar || lastBar
44 17 const prev = hover ? hover.prev : prevBar
45 18 const ref = prev ? prev.c : bar?.o
46 19 const delta = bar && ref != null ? bar.c - ref : null
47 20 const pct = delta != null && ref ? (delta / ref) * 100 : null
48 21 const dir = delta == null ? 'neutral' : delta >= 0 ? 'up' : 'down'
49 − const stamp = bar ? formatStampLabel(bar.t, state.tf, state.tf === '1day' ? '' : tz) : ''
22 + const stamp = bar ? formatStampLabel(bar.t, cs.tf, cs.tf === '1day' ? '' : tz) : ''
50 23
51 24 // throttled live text (≤ 1 update / 600 ms)
52 25 const [live, setLive] = useState('')
53 26 const liveRef = useRef({ t: 0, timer: 0 })
54 27 useEffect(() => {
55 28 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` : ''}`
29 + const text = `${cs.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 30 const now = Date.now()
58 31 const wait = Math.max(0, 600 - (now - liveRef.current.t))
59 32 clearTimeout(liveRef.current.timer)
60 33 liveRef.current.timer = setTimeout(() => { liveRef.current.t = Date.now(); setLive(text) }, wait)
61 34 return () => clearTimeout(liveRef.current.timer)
62 − }, [bar, stamp, decimals, pct, state.label])
35 + }, [bar, stamp, decimals, pct, cs.label])
63 36
64 37 return (
65 38 <div className={`ch-legend ${compact ? 'is-compact' : ''}`} data-testid="ch-legend">
66 39 <div className="ch-legend-head">
67 − <span className="ch-legend-sym mono">{state.label}</span>
40 + <span className="ch-legend-sym mono">{cs.label}</span>
68 41 {name && <span className="ch-legend-name">{name}</span>}
69 − <span className="ch-legend-tf">{TF_LABEL[state.tf]}</span>
42 + <span className="ch-legend-tf">{TF_LABEL[cs.tf]}</span>
70 43 <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>}
44 + {cs.adjustment && <span className="ch-legend-adj">{cs.adjustment}</span>}
45 + {replayLabel && <span className="ch-legend-replay" data-testid="ch-legend-replay">{replayLabel}</span>}
72 46 </div>
73 47 {bar ? (
74 48 <div className="ch-legend-ohlc mono" data-testid="ch-legend-ohlc">
@@ -82,31 +56,41 @@ export default function Legend({ state, name, hover, lastBar, prevBar, indValues
82 56 {bar.oi != null && <span>OI <b>{formatVolume(bar.oi)}</b></span>}
83 57 </div>
84 58 ) : <div className="ch-legend-ohlc mono muted">Loading…</div>}
85 − {state.indicators.map(ind => {
59 + {cs.indicators.map(ind => {
86 60 const def = indicatorDef(ind.type)
87 61 const color = seriesColor(theme, ind.colorIndex ?? 0)
88 62 const vals = indValues?.[ind.id] || {}
63 + const colors = plotColors?.[ind.id] || {}
64 + const keys = Object.keys(vals).length ? Object.keys(vals) : (def?.outputs || ['value'])
65 + const label = indicatorShortLabel(ind)
89 66 return (
90 − <div key={ind.id} className={`ch-legend-row ${editing === ind.id ? 'is-editing' : ''}`} data-testid="ch-legend-ind">
67 + <div key={ind.id} className={`ch-legend-row ${ind.hidden ? 'is-hidden' : ''}`} data-testid="ch-legend-ind" onDoubleClick={() => onOpenSettings(ind.id)}>
91 68 <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 − {(() => { const keys = Object.keys(vals).length ? Object.keys(vals) : (def?.outputs || ['value']); return keys.map(k => <span key={k}>{keys.length > 1 && <i>{k} </i>}{fmtVal(vals[k], decimals)}</span>) })()}
69 + <button type="button" className="ch-legend-btn" onClick={() => onOpenSettings(ind.id)} title="Settings (double-click)">{label}</button>
70 + {!ind.hidden && (
71 + <span className="ch-legend-vals mono">
72 + {keys.map(k => <span key={k} style={{ color: ind.plots?.[k]?.color || colors[k] || undefined }}>{keys.length > 1 && <i>{k} </i>}{fmtVal(vals[k], decimals)}</span>)}
73 + </span>
74 + )}
75 + {ind.hidden && <span className="ch-legend-vals muted">hidden</span>}
76 + <span className="ch-legend-actions">
77 + <button type="button" className="ch-legend-x" aria-pressed={!!ind.hidden} aria-label={ind.hidden ? `Show ${label}` : `Hide ${label}`} onClick={() => onToggleHidden(ind.id)}>{ind.hidden ? <EyeOffIcon /> : <EyeIcon />}</button>
78 + <button type="button" className="ch-legend-x" aria-label={`Settings of ${label}`} onClick={() => onOpenSettings(ind.id)} data-testid="ch-legend-gear"><GearIcon /></button>
79 + <button type="button" className="ch-legend-x" aria-label={`Remove ${label}`} onClick={() => onRemoveIndicator(ind.id)}><CloseIcon /></button>
95 80 </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 81 </div>
99 82 )
100 83 })}
101 − {state.compares.map(c => (
84 + {cs.compares.map(c => (
102 85 <div key={c.id} className="ch-legend-row" data-testid="ch-legend-cmp">
103 86 <span className="ch-swatch" style={{ background: seriesColor(theme, c.colorIndex ?? 0) }} aria-hidden="true" />
104 − <span className="ch-legend-btn mono">{c.ticker}</span>
87 + <span className="ch-legend-btn mono"><i className="ch-legend-cmp-tag">Compare</i> {c.ticker}</span>
105 88 <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>
89 + <span className="ch-legend-actions"><button type="button" className="ch-legend-x" aria-label={`Remove comparison ${c.ticker}`} onClick={() => onRemoveCompare(c.id)}><CloseIcon /></button></span>
107 90 </div>
108 91 ))}
109 92 <span className="sr-only" aria-live="polite" aria-atomic="true">{live}</span>
93 + <span className="sr-only" aria-live="polite" aria-atomic="true" data-testid="ch-legend-describe">{describe}</span>
110 94 </div>
111 95 )
112 96 }
modified hfmarketdata/web/src/pages/charts/Menu.jsx +5 −5
@@ -7,7 +7,7 @@ import { ChevronDownIcon } from '../../components/Icons.jsx'
7 7 const Ctx = createContext({ close: () => {} })
8 8 export const useMenu = () => useContext(Ctx)
9 9
10 −export default function Menu({ label, icon, value, children, align = 'start', className = '', wide = false, testId, title, hideLabelOnNarrow = false, onOpenChange }) {
10 +export default function Menu({ label, icon, value, children, align = 'start', className = '', wide = false, testId, title, hideLabelOnNarrow = false, iconOnly = false, onOpenChange }) {
11 11 const [open, setOpen] = useState(false)
12 12 const id = useId()
13 13 const wrapRef = useRef(null)
@@ -38,11 +38,11 @@ export default function Menu({ label, icon, value, children, align = 'start', cl
38 38
39 39 return (
40 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}>
41 + <button ref={btnRef} type="button" className={`ch-tb-btn ${open ? 'is-open' : ''} ${hideLabelOnNarrow ? 'ch-hide-label' : ''} ${iconOnly ? 'ch-tb-icon' : ''}`} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
42 + aria-label={iconOnly ? label : undefined} onClick={() => { setOpen(o => { onOpenChange?.(!o); return !o }) }} data-testid={testId} title={title || label}>
43 43 {icon}
44 − {(label || value) && <span className="ch-tb-label">{value ?? label}</span>}
45 − <ChevronDownIcon className="ch-chev" />
44 + {!iconOnly && (label || value) && <span className="ch-tb-label">{value ?? label}</span>}
45 + {!iconOnly && <ChevronDownIcon className="ch-chev" />}
46 46 </button>
47 47 {open && (
48 48 <Ctx.Provider value={{ close }}>
added hfmarketdata/web/src/pages/charts/ReplayBar.jsx +28 −0
@@ -0,0 +1,28 @@
1 +// Bar replay control bar: play / pause, step back / forward, speed 1×–10×, "jump to bar" (click on the chart),
2 +// progress slider, exit. Indicators are recomputed by the engine (v2 `setReplay`) or by the page's emulation
3 +// (v1: `setData(bars.slice(0, i + 1))`).
4 +import React from 'react'
5 +import { formatStampLabel } from '../../charts/data/session.js'
6 +import { CloseIcon } from '../../components/Icons.jsx'
7 +import { PauseIcon, PlayIcon, StepBackIcon, StepFwdIcon, TargetIcon } from './icons.jsx'
8 +
9 +export const SPEEDS = [1, 2, 3, 5, 10]
10 +
11 +export default function ReplayBar({ replay, total, bar, tf, tz, onToggle, onStep, onSpeed, onJumpMode, onSeek, onExit, compact = false }) {
12 + const { index, playing, speed, jumping } = replay
13 + return (
14 + <div className={`ch-replay ${compact ? 'is-compact' : ''}`} role="toolbar" aria-label="Bar replay" data-testid="ch-replay">
15 + <span className="ch-replay-tag">Replay</span>
16 + <button type="button" className="ch-tool" aria-label="Step back" title="Step back (←)" onClick={() => onStep(-1)} disabled={index <= 0}><StepBackIcon /></button>
17 + <button type="button" className="ch-tool" aria-pressed={playing} aria-label={playing ? 'Pause' : 'Play'} title={playing ? 'Pause (Space)' : 'Play (Space)'} onClick={onToggle} data-testid="ch-replay-play">{playing ? <PauseIcon /> : <PlayIcon />}</button>
18 + <button type="button" className="ch-tool" aria-label="Step forward" title="Step forward (→)" onClick={() => onStep(1)} disabled={index >= total - 1} data-testid="ch-replay-fwd"><StepFwdIcon /></button>
19 + <select className="select ch-replay-speed" value={speed} aria-label="Replay speed" onChange={e => onSpeed(Number(e.target.value))} data-testid="ch-replay-speed">
20 + {SPEEDS.map(s => <option key={s} value={s}>{s}×</option>)}
21 + </select>
22 + <button type="button" className="ch-tool" aria-pressed={jumping} aria-label="Jump to bar: click on the chart" title="Jump to bar — then click on the chart" onClick={() => onJumpMode(!jumping)} data-testid="ch-replay-jump"><TargetIcon /></button>
23 + <input type="range" className="ch-replay-slider" min={0} max={Math.max(0, total - 1)} value={Math.min(index, Math.max(0, total - 1))} aria-label="Replay position" onChange={e => onSeek(Number(e.target.value))} data-testid="ch-replay-slider" />
24 + <span className="ch-replay-pos mono" data-testid="ch-replay-pos">{bar ? formatStampLabel(bar.t, tf, tf === '1day' ? '' : tz) : '—'} · {index + 1}/{total}</span>
25 + <button type="button" className="icon-btn" aria-label="Exit replay" title="Exit replay" onClick={onExit} data-testid="ch-replay-exit"><CloseIcon /></button>
26 + </div>
27 + )
28 +}
added hfmarketdata/web/src/pages/charts/ShortcutsDialog.jsx +48 −0
@@ -0,0 +1,48 @@
1 +// Keyboard shortcuts panel (?).
2 +import React from 'react'
3 +import Dialog from './Dialog.jsx'
4 +import { ALL_TOOLS, toolSupported } from './drawtools.js'
5 +
6 +const K = ({ children }) => <kbd>{children}</kbd>
7 +
8 +export default function ShortcutsDialog({ open, onClose }) {
9 + const tools = ALL_TOOLS.filter(t => t.key && toolSupported(t.id))
10 + const rows = [
11 + ['Navigation', [
12 + [<><K>←</K> <K>→</K></>, 'Pan one step · in replay: step back / forward'],
13 + [<><K>+</K> <K>−</K></>, 'Zoom in / out'],
14 + [<><K>Home</K> <K>End</K></>, 'Oldest / latest bar'],
15 + ['Wheel · drag · double-click', 'Zoom around the cursor · pan · fit the series'],
16 + ['Right-click', 'Context menu (reset view, lines, alert, copy price, PNG, table)'],
17 + ]],
18 + ['Symbols & layout', [
19 + [<><K>/</K> or <K>⌘</K> <K>/</K></>, 'Focus the symbol box'],
20 + [<><K>⌘</K> <K>I</K></>, 'Indicator library'],
21 + [<><K>⌘</K> <K>K</K></>, 'Chart templates'],
22 + [<><K>Alt</K> <K>1</K> … <K>4</K></>, 'Layout: single · 2 side by side · 2 stacked · 4 grid'],
23 + [<><K>Tab</K> (on the chart)</>, 'Next chart of the layout'],
24 + [<><K>Shift</K> <K>R</K></>, 'Bar replay on / off'],
25 + [<><K>Space</K></>, 'Replay: play / pause'],
26 + [<K>?</K>, 'This panel'],
27 + ]],
28 + ['Drawings', [
29 + ...tools.map(t => [<K key={t.id}>{t.key}</K>, t.label]),
30 + [<K>Esc</K>, 'Back to the cursor (cancels the tool, closes menus)'],
31 + [<><K>Delete</K> / <K>Backspace</K></>, 'Remove the selected drawing'],
32 + [<><K>⌘</K> <K>Z</K> · <K>⌘</K> <K>⇧</K> <K>Z</K> / <K>⌘</K> <K>Y</K></>, 'Undo · redo'],
33 + [<><K>⌘</K> <K>D</K></>, 'Duplicate the selected drawing'],
34 + ]],
35 + ]
36 + return (
37 + <Dialog open={open} onClose={onClose} title="Keyboard shortcuts" testId="ch-shortcuts" size="md">
38 + {rows.map(([title, list]) => (
39 + <section key={title} className="ch-keys">
40 + <h3>{title}</h3>
41 + <dl>
42 + {list.map(([k, d], i) => <div key={i} className="ch-keys-row"><dt>{k}</dt><dd>{d}</dd></div>)}
43 + </dl>
44 + </section>
45 + ))}
46 + </Dialog>
47 + )
48 +}
modified hfmarketdata/web/src/pages/charts/Toolbar.jsx +34 −23
@@ -1,28 +1,31 @@
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).
1 +// Top toolbar (one row), bound to the ACTIVE chart of the layout: symbol · timeframe · series type · indicators ·
2 +// compare · adjustment … layout · templates · replay · scale · auto · magnet · settings · table · screenshot ·
3 +// fullscreen · share · shortcuts. Under 640 px only symbol + timeframe + pencil + "⋯" stay (the rest lives in the
4 +// bottom sheet rendered by the page).
4 5 import React from 'react'
5 6 import CopyButton from '../../components/CopyButton.jsx'
6 7 import { TIMEFRAMES, TF_LABEL } from '../../charts/data/bars.js'
7 8 import { ADJUSTMENTS } from '../../charts/data/symbols.js'
8 −import { SERIES_TYPES } from '../../charts/data/state.js'
9 +import { LAYOUTS, SERIES_TYPES } from '../../charts/data/state.js'
9 10 import Menu from './Menu.jsx'
10 11 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'
12 +import { AdjustmentPanel, ComparePanel, IndicatorsPanel, LayoutPanel, ScalePanel, SeriesTypePanel, SettingsPanel, TemplatesPanel } from './panels.jsx'
13 +import { AutoIcon, CalendarIcon, CameraIcon, CompareIcon, DotsIcon, ExpandIcon, GearIcon, IndicatorIcon, KeyboardIcon, LAYOUT_ICONS, MagnetIcon, PencilIcon, ReplayIcon, ScaleIcon, SERIES_ICONS, ShrinkIcon, TableIcon, TemplateIcon } from './icons.jsx'
13 14
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 hasCompare = state.compares.length > 0
18 − const scale = hasCompare ? 'percent' : state.scale // comparisons force the percent scale (engine contract)
15 +export default function Toolbar({ cs, page, prefs, a, fullscreen, table, drawbarOpen, symbolRef, isMobile, replayOn, templates, favorites }) {
16 + const TypeIcon = SERIES_ICONS[cs.type] || SERIES_ICONS.candles
17 + const typeLabel = SERIES_TYPES.find(([id]) => id === cs.type)?.[1] || 'Candles'
18 + const hasCompare = cs.compares.length > 0
19 + const scale = hasCompare ? 'percent' : cs.scale // comparisons force the percent scale (engine contract)
19 20 const scaleLabel = scale === 'log' ? 'Log' : scale === 'percent' ? '%' : 'Lin'
21 + const LayoutIcon = LAYOUT_ICONS[page.layout] || LAYOUT_ICONS['1']
22 + const multi = (LAYOUTS[page.layout]?.count || 1) > 1
20 23 const shareUrl = () => window.location.href
21 24 return (
22 25 <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar">
23 − <SymbolSearch ref={symbolRef} current={state.label} onPick={a.pickSymbol} apiKey={apiKey} compact={isMobile} />
26 + <SymbolSearch ref={symbolRef} current={cs.label} onPick={a.pickSymbol} compact={isMobile} />
24 27 <div className="ch-seg" role="group" aria-label="Timeframe" data-testid="ch-tf">
25 − {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>)}
28 + {TIMEFRAMES.map(tf => <button key={tf} type="button" className="ch-seg-btn" aria-pressed={cs.tf === tf} onClick={() => a.setTf(tf)} data-testid={`ch-tf-${tf}`}>{TF_LABEL[tf]}</button>)}
26 29 </div>
27 30 {isMobile ? (
28 31 <>
@@ -32,32 +35,40 @@ export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullsc
32 35 ) : (
33 36 <>
34 37 <Menu label="Series type" value={typeLabel} icon={<TypeIcon />} testId="ch-type-menu" hideLabelOnNarrow>
35 − <SeriesTypePanel value={state.type} onChange={a.setType} />
38 + <SeriesTypePanel value={cs.type} onChange={a.setType} />
36 39 </Menu>
37 − <Menu label="Indicators" icon={<IndicatorIcon />} testId="ch-ind-menu" wide hideLabelOnNarrow>
38 − <IndicatorsPanel onAdd={a.addIndicator} active={state.indicators.map(i => i.type)} />
40 + <Menu label="Indicators" icon={<IndicatorIcon />} testId="ch-ind-menu" wide hideLabelOnNarrow value={cs.indicators.length ? `Indicators (${cs.indicators.length})` : undefined}>
41 + {close => <IndicatorsPanel onAdd={a.addIndicator} active={cs.indicators.map(i => i.type)} favorites={favorites} onOpenLibrary={() => { close(); a.openLibrary() }} />}
39 42 </Menu>
40 − <Menu label="Compare" icon={<CompareIcon />} testId="ch-cmp-menu" wide hideLabelOnNarrow value={hasCompare ? `Compare (${state.compares.length})` : undefined}>
41 − <ComparePanel compares={state.compares} onAdd={a.addCompare} onRemove={a.removeCompare} apiKey={apiKey} />
43 + <Menu label="Compare" icon={<CompareIcon />} testId="ch-cmp-menu" wide hideLabelOnNarrow value={hasCompare ? `Compare (${cs.compares.length})` : undefined}>
44 + <ComparePanel compares={cs.compares} onAdd={a.addCompare} onRemove={a.removeCompare} />
42 45 </Menu>
43 − {ADJUSTMENTS[state.asset] && (
44 − <Menu label="Adjustment" icon={<CalendarIcon />} testId="ch-adj-menu" value={(state.adjustment || ADJUSTMENTS[state.asset][0][0]).replace('contin_', '').replace('adj_', '')} hideLabelOnNarrow>
45 − <AdjustmentPanel asset={state.asset} value={state.adjustment} onChange={a.setAdjustment} />
46 + {ADJUSTMENTS[cs.asset] && (
47 + <Menu label="Adjustment" icon={<CalendarIcon />} testId="ch-adj-menu" value={(cs.adjustment || ADJUSTMENTS[cs.asset][0][0]).replace('contin_', '').replace('adj_', '')} hideLabelOnNarrow>
48 + <AdjustmentPanel asset={cs.asset} value={cs.adjustment} onChange={a.setAdjustment} />
46 49 </Menu>
47 50 )}
48 51 <span className="ch-tb-spacer" />
52 + <Menu label="Layout" icon={<LayoutIcon />} testId="ch-layout-menu" title={`Layout: ${LAYOUTS[page.layout]?.label || 'Single'}`} align="end" iconOnly>
53 + <LayoutPanel value={page.layout} onChange={a.setLayout} />
54 + </Menu>
55 + <Menu label="Templates" icon={<TemplateIcon />} testId="ch-tpl-menu" wide title="Chart templates (⌘K)" align="end" iconOnly>
56 + <TemplatesPanel templates={templates} onApply={a.applyTemplate} onSave={a.saveTemplate} onDelete={a.deleteTemplate} onSetDefault={a.setDefaultTemplate} onExport={a.exportTemplates} onImport={a.importTemplates} />
57 + </Menu>
58 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={replayOn} aria-label="Bar replay" title="Bar replay (Shift+R)" onClick={a.toggleReplay} data-testid="ch-replay-btn"><ReplayIcon /></button>
49 59 <Menu label="Price scale" value={scaleLabel} icon={<ScaleIcon />} align="end" testId="ch-scale-menu">
50 60 <ScalePanel scale={scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={hasCompare} />
51 61 </Menu>
52 62 <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>
53 63 <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>
54 − <Menu label="Settings" icon={<GearIcon />} align="end" testId="ch-settings-menu" wide>
55 − <SettingsPanel prefs={prefs} onPrefs={a.setPrefs} volume={state.volume} onVolume={a.setVolume} apiKey={apiKey} onApiKey={a.setApiKey} authenticated={authenticated} />
64 + <Menu label="Settings" icon={<GearIcon />} align="end" testId="ch-settings-menu" wide hideLabelOnNarrow>
65 + <SettingsPanel prefs={prefs} onPrefs={a.setPrefs} volume={cs.volume} onVolume={a.setVolume} multi={multi} />
56 66 </Menu>
57 67 <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>
58 68 <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>
59 69 <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>
60 70 <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" />
71 + <button type="button" className="ch-tb-btn ch-tb-icon" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={a.openShortcuts} data-testid="ch-shortcuts-btn"><KeyboardIcon /></button>
61 72 </>
62 73 )}
63 74 </div>
modified hfmarketdata/web/src/pages/charts/charts.css +148 −9
@@ -84,9 +84,16 @@
84 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 85 .ch-drawbar.is-horizontal .ch-drawbar-sep { width: 1px; height: 20px; margin: 0 4px; }
86 86
87 −.ch-stage { position: relative; flex: 1; min-width: 0; min-height: 0; }
87 +.ch-stage { position: relative; flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
88 +.ch-grid { flex: 1; min-height: 0; display: grid; gap: 2px; background: var(--line); }
89 +.ch-layout-1 .ch-grid { grid-template-columns: 1fr; }
90 +.ch-layout-2h .ch-grid { grid-template-columns: 1fr 1fr; }
91 +.ch-layout-2v .ch-grid { grid-template-rows: 1fr 1fr; }
92 +.ch-layout-4 .ch-grid { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
93 +.ch-view { position: relative; min-width: 0; min-height: 0; background: var(--bg); outline: none; }
94 +.ch-view.is-active { box-shadow: inset 0 0 0 1px var(--accent); }
88 95 .ch-canvas { position: absolute; inset: 0; transition: opacity .2s; }
89 −.ch-stage.is-stale .ch-canvas { opacity: .45; }
96 +.ch-view.is-stale .ch-canvas { opacity: .45; }
90 97 .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 98 .ch-skeleton-bars { display: flex; align-items: flex-end; gap: 6px; height: 60%; }
92 99 .ch-skeleton-bars .skeleton { flex: 1; width: auto; border-radius: 2px; }
@@ -113,10 +120,17 @@
113 120 button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: var(--bg-2); color: var(--fg); }
114 121 .ch-legend-vals { display: flex; gap: 8px; color: var(--fg-2); font-variant-numeric: tabular-nums; }
115 122 .ch-legend-vals i { font-style: normal; color: var(--fg-3); }
123 +.ch-legend-actions { display: inline-flex; gap: 2px; }
116 124 .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; }
117 −.ch-legend-x svg { width: 12px; height: 12px; }
118 −.ch-legend-row:hover .ch-legend-x, .ch-legend-x:focus-visible, .ch-legend.is-compact .ch-legend-x { opacity: 1; }
119 −.ch-legend-x:hover { color: var(--danger); background: var(--danger-soft); }
125 +.ch-legend-x svg { width: 13px; height: 13px; }
126 +.ch-legend-x[disabled] { opacity: .3 !important; cursor: not-allowed; }
127 +.ch-legend-row:hover .ch-legend-x, .ch-legend-x:focus-visible, .ch-legend.is-compact .ch-legend-x, .ch-legend-row:focus-within .ch-legend-x, .ch-lib-active-row .ch-legend-x, .ch-tpl-row .ch-legend-x { opacity: 1; }
128 +.ch-legend-x:hover { color: var(--fg); background: var(--bg-2); }
129 +.ch-legend-x[aria-label^="Remove"]:hover, .ch-legend-x[aria-label^="Delete"]:hover { color: var(--danger); background: var(--danger-soft); }
130 +.ch-legend-x.is-on, .ch-lib-star.is-on { color: var(--accent); }
131 +.ch-legend-row.is-hidden .ch-legend-btn { color: var(--fg-3); text-decoration: line-through; }
132 +.ch-legend-replay { padding: 0 6px; border-radius: 4px; background: var(--accent-soft); color: var(--accent-strong); font-family: var(--mono); font-size: 11px; line-height: 18px; }
133 +.ch-legend-cmp-tag { font-style: normal; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
120 134 .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); }
121 135 .ch-ind-editor-title { font-weight: 600; color: var(--fg); }
122 136 .ch-ind-param { display: grid; grid-template-columns: 1fr 90px; align-items: center; gap: 8px; color: var(--fg-2); }
@@ -136,8 +150,8 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
136 150 .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; }
137 151 .ch-status-item { display: inline-flex; align-items: center; gap: 6px; }
138 152 .ch-status-spacer { flex: 1; }
139 −.ch-status-quota { color: var(--fg-2); }
140 −.ch-status-cta { margin-left: 8px; color: var(--accent); font-weight: 500; }
153 +.ch-status-retry { border: 0; background: transparent; color: var(--accent); font: inherit; font-size: var(--fs-0); cursor: pointer; }
154 +.ch-status-retry:hover { text-decoration: underline; }
141 155 .ch-status-tz { font-family: var(--mono); color: var(--fg-3); }
142 156 .ch-status-range { color: var(--fg-3); }
143 157
@@ -154,10 +168,12 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
154 168 .ch-sheet-btn svg { width: 18px; height: 18px; margin-right: 6px; }
155 169
156 170 /* ---- responsive ------------------------------------------------------------------------------------------------ */
157 −@media (max-width: 1200px) {
171 +@media (max-width: 1440px) {
158 172 .ch-hide-label .ch-tb-label, .ch-hide-label .ch-chev { display: none; }
159 173 .ch-hide-label { width: var(--tap); padding: 0; justify-content: center; }
160 174 .ch-symbol { width: 190px; }
175 +}
176 +@media (max-width: 1200px) {
161 177 .ch-status-range { display: none; }
162 178 }
163 179 @media (max-width: 960px) {
@@ -180,7 +196,130 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
180 196 .ch-legend { top: 6px; left: 8px; max-width: calc(100% - 76px); }
181 197 .ch-legend-ohlc span:nth-child(n+7) { display: none; }
182 198 .ch-status { gap: 8px; padding: 0 8px; }
183 − .ch-status-cta { display: none; }
184 199 .ch-skeleton { padding: 40px 70px 30px 12px; }
185 200 }
186 201 @media (prefers-reduced-motion: reduce) { .ch-canvas { transition: none; } .ch-spin { animation-duration: 1.6s; } }
202 +
203 +/* ---- drawing bar: groups + flyouts ------------------------------------------------------------------------------ */
204 +.ch-toolgroup { position: relative; display: flex; flex-direction: column; align-items: center; flex: none; }
205 +.ch-tool-more { position: absolute; right: 0; bottom: 0; display: grid; place-items: center; width: 12px; height: 12px; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--fg-3); cursor: pointer; opacity: 0; }
206 +.ch-tool-more svg { width: 9px; height: 9px; transform: rotate(-90deg); }
207 +.ch-toolgroup:hover .ch-tool-more, .ch-tool-more:focus-visible, .ch-toolgroup.is-open .ch-tool-more { opacity: 1; }
208 +.ch-tool-more[aria-expanded="true"] { color: var(--accent); }
209 +.ch-flyout { position: absolute; left: calc(100% + 6px); top: 0; z-index: var(--z-drawer); min-width: 240px; padding: 4px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
210 +.ch-flyout.is-below { left: 0; top: calc(100% + 6px); }
211 +.ch-flyout-title { padding: 6px 10px 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
212 +.ch-flyout-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px; 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; }
213 +.ch-flyout-item svg { width: 18px; height: 18px; flex: none; color: var(--fg-2); }
214 +.ch-flyout-item span { flex: 1; }
215 +.ch-flyout-item kbd { font-size: 10px; color: var(--fg-3); }
216 +.ch-flyout-item:hover, .ch-flyout-item:focus-visible { background: var(--bg-2); color: var(--fg); }
217 +.ch-flyout-item[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); }
218 +.ch-drawbar.is-horizontal .ch-toolgroup { flex-direction: row; }
219 +.ch-drawbar.is-horizontal .ch-tool-more { position: static; opacity: 1; width: 14px; height: 40px; }
220 +.ch-drawbar.is-horizontal .ch-tool-more svg { transform: none; }
221 +
222 +/* ---- floating drawing properties -------------------------------------------------------------------------------- */
223 +.ch-props { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); z-index: 5; display: flex; align-items: center; gap: 6px; max-width: calc(100% - 24px); padding: 4px 8px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
224 +.ch-props.is-sheet { position: static; transform: none; flex-wrap: wrap; box-shadow: none; border: 0; background: transparent; padding: 4px 0; }
225 +.ch-props-name { font-size: var(--fs-0); font-weight: 600; color: var(--fg); white-space: nowrap; }
226 +.ch-props-field .select { min-height: 30px; height: 30px; padding: 0 6px; font-size: var(--fs-0); }
227 +.ch-props-fill { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-0); color: var(--fg-2); }
228 +.ch-props-fill input { width: 70px; }
229 +.ch-props-text { width: 140px; min-height: 30px; height: 30px; padding: 0 8px; font-size: var(--fs-0); }
230 +.ch-props-spacer { width: 4px; }
231 +.ch-props .ch-tool { width: 30px; height: 30px; }
232 +.ch-colors { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; }
233 +.ch-color { width: 18px; height: 18px; padding: 0; border: 2px solid transparent; border-radius: 50%; cursor: pointer; }
234 +.ch-color[aria-checked="true"] { border-color: var(--fg); box-shadow: 0 0 0 1px var(--bg); }
235 +.ch-colors input[type="color"] { width: 22px; height: 22px; padding: 0; border: 1px solid var(--line-2); border-radius: 50%; background: transparent; cursor: pointer; }
236 +.ch-textedit { position: absolute; z-index: 6; }
237 +.ch-textedit .input { min-width: 160px; }
238 +
239 +/* ---- replay ----------------------------------------------------------------------------------------------------- */
240 +.ch-replay { display: flex; align-items: center; gap: 6px; height: 40px; padding: 0 10px; border-top: 1px solid var(--line); background: var(--bg-1); flex: none; }
241 +.ch-replay-tag { padding: 0 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent-strong); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; line-height: 20px; }
242 +.ch-replay .ch-tool { width: 30px; height: 30px; }
243 +.ch-replay-speed { min-height: 30px; height: 30px; padding: 0 6px; font-size: var(--fs-0); }
244 +.ch-replay-slider { flex: 1; min-width: 80px; }
245 +.ch-replay-pos { font-size: var(--fs-0); color: var(--fg-2); white-space: nowrap; }
246 +.ch-replay.is-compact { flex-wrap: wrap; height: auto; padding: 6px 8px; }
247 +.ch-replay.is-compact .ch-replay-slider { order: 9; flex-basis: 100%; }
248 +
249 +/* ---- context menu ----------------------------------------------------------------------------------------------- */
250 +.ch-ctx { position: fixed; z-index: var(--z-modal); min-width: 240px; padding: 4px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); }
251 +.ch-ctx .ch-item { min-height: 36px; }
252 +
253 +/* ---- dialogs ---------------------------------------------------------------------------------------------------- */
254 +.ch-dialog { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: calc(var(--z-modal) + 1); display: flex; flex-direction: column; width: min(560px, calc(100vw - 32px)); max-height: min(80vh, 720px); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-lg, 12px); box-shadow: var(--shadow-2); }
255 +.ch-dialog.is-lg { width: min(860px, calc(100vw - 32px)); }
256 +.ch-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 10px 8px 16px; border-bottom: 1px solid var(--line); }
257 +.ch-dialog-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 16px; }
258 +.ch-dialog-foot { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); }
259 +.ch-tabs { display: flex; gap: 2px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
260 +.ch-lib-tab { min-height: 34px; padding: 0 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--fg-2); font: inherit; font-size: var(--fs-1); font-weight: 500; cursor: pointer; white-space: nowrap; }
261 +.ch-lib-tab:hover { color: var(--fg); }
262 +.ch-lib-tab[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
263 +
264 +/* indicator library */
265 +.ch-lib { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 16px; min-height: 380px; }
266 +.ch-lib-main { display: flex; flex-direction: column; min-width: 0; }
267 +.ch-lib-search .input { width: 100%; }
268 +.ch-lib-tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
269 +.ch-lib-list { flex: 1; min-height: 0; max-height: 52vh; overflow: auto; margin: 0; padding: 0; list-style: none; }
270 +.ch-lib-item { display: flex; align-items: center; gap: 8px; border-radius: var(--r-sm); }
271 +.ch-lib-item:hover { background: var(--bg-2); }
272 +.ch-lib-add { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 8px 10px; border: 0; background: transparent; color: var(--fg); font: inherit; text-align: left; cursor: pointer; border-radius: var(--r-sm); }
273 +.ch-lib-add:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
274 +.ch-lib-name { font-weight: 600; font-size: var(--fs-1); }
275 +.ch-lib-count { margin-left: 8px; font-size: 11px; color: var(--fg-3); font-weight: 400; }
276 +.ch-lib-desc { font-size: var(--fs-0); color: var(--fg-2); }
277 +.ch-lib-meta { font-size: 11px; color: var(--fg-3); white-space: nowrap; }
278 +.ch-lib-star { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--fg-3); cursor: pointer; flex: none; }
279 +.ch-lib-star svg { width: 16px; height: 16px; }
280 +.ch-lib-star:hover { color: var(--fg); background: var(--bg-3); }
281 +.ch-lib-empty { padding: 12px; }
282 +.ch-lib-active { border-left: 1px solid var(--line); padding-left: 16px; min-width: 0; }
283 +.ch-lib-active-title { margin-bottom: 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
284 +.ch-lib-active-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; }
285 +.ch-lib-active-row { display: flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 4px; border-radius: var(--r-sm); font-size: var(--fs-0); }
286 +.ch-lib-active-row:hover { background: var(--bg-2); }
287 +.ch-lib-active-row.is-hidden .ch-lib-active-name { color: var(--fg-3); text-decoration: line-through; }
288 +.ch-lib-active-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
289 +.ch-lib-active-actions { display: inline-flex; gap: 0; }
290 +
291 +/* indicator settings */
292 +.ch-set-form { display: grid; gap: 8px; }
293 +.ch-set-row { display: grid; grid-template-columns: 1fr minmax(120px, 200px); align-items: center; gap: 10px; color: var(--fg-1); font-size: var(--fs-1); }
294 +.ch-set-row .select, .ch-set-row .input { min-height: 32px; height: 32px; padding: 0 8px; }
295 +.ch-set-num { display: flex; align-items: center; gap: 8px; }
296 +.ch-set-num input[type="range"] { flex: 1; min-width: 60px; }
297 +.ch-set-num .input { width: 80px; }
298 +.ch-set-desc { margin: 4px 0 0; }
299 +.ch-set-plot { margin: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-md); display: grid; gap: 6px; }
300 +.ch-set-plot legend { padding: 0 6px; font-size: var(--fs-0); color: var(--fg-2); }
301 +
302 +/* templates */
303 +.ch-tpl-save { display: flex; gap: 6px; }
304 +.ch-tpl-save .input { flex: 1; }
305 +.ch-tpl-row { display: flex; align-items: center; gap: 2px; }
306 +.ch-tpl-row .ch-item { flex: 1; }
307 +
308 +/* shortcuts */
309 +.ch-keys h3 { margin: 8px 0 6px; font-size: var(--fs-1); color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; }
310 +.ch-keys dl { margin: 0; display: grid; gap: 4px; }
311 +.ch-keys-row { display: grid; grid-template-columns: 200px 1fr; gap: 10px; align-items: baseline; font-size: var(--fs-1); }
312 +.ch-keys-row dt { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; color: var(--fg); }
313 +.ch-keys-row dd { margin: 0; color: var(--fg-2); }
314 +.ch-keys kbd { display: inline-block; min-width: 20px; padding: 1px 6px; border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 4px; background: var(--bg-2); font-family: var(--mono); font-size: 11px; text-align: center; }
315 +
316 +@media (max-width: 760px) {
317 + .ch-lib { grid-template-columns: 1fr; }
318 + .ch-lib-active { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 10px; }
319 + .ch-keys-row { grid-template-columns: 1fr; gap: 2px; }
320 + .ch-set-row { grid-template-columns: 1fr; gap: 4px; }
321 +}
322 +@media (max-width: 640px) {
323 + .ch-layout-2h .ch-grid, .ch-layout-4 .ch-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 1fr; }
324 + .ch-flyout { display: none; }
325 +}
added hfmarketdata/web/src/pages/charts/drawtools.js +79 −0
@@ -0,0 +1,79 @@
1 +// Drawing tools of the /charts page, grouped like a professional terminal's flyout bar. Every tool id is the engine's
2 +// `setDrawingTool(id)` value; tools the loaded engine does not list (`DRAWING_TOOLS` of engine v1) are hidden, so
3 +// the bar works with the v1 engine today and shows the full arsenal with the v2 engine (additive contract).
4 +import { DRAWING_TOOLS } from '../../charts/engine/index.js'
5 +
6 +export const GROUPS = [
7 + { id: 'lines', label: 'Lines', tools: [
8 + { id: 'trendline', label: 'Trend line', key: 'T' },
9 + { id: 'ray', label: 'Ray' },
10 + { id: 'extended', label: 'Extended line' },
11 + { id: 'hray', label: 'Horizontal ray' },
12 + { id: 'hline', label: 'Horizontal line', key: 'H' },
13 + { id: 'vline', label: 'Vertical line', key: 'V' },
14 + { id: 'cross', label: 'Cross line', key: 'C' },
15 + { id: 'channel', label: 'Parallel channel' },
16 + { id: 'channel-parallel', label: 'Parallel channel (3 points)' },
17 + { id: 'regression', label: 'Regression trend' },
18 + { id: 'pitchfork', label: 'Andrews pitchfork', key: 'P' },
19 + { id: 'schiff', label: 'Schiff pitchfork' },
20 + ] },
21 + { id: 'fib', label: 'Fibonacci & Gann', tools: [
22 + { id: 'fib', label: 'Fibonacci retracement', key: 'F' },
23 + { id: 'fib-extension', label: 'Fibonacci extension' },
24 + { id: 'fib-timezones', label: 'Fibonacci time zones' },
25 + { id: 'fib-fan', label: 'Fibonacci fan' },
26 + { id: 'fib-arcs', label: 'Fibonacci arcs' },
27 + { id: 'gann-fan', label: 'Gann fan', key: 'G' },
28 + { id: 'gann-box', label: 'Gann box' },
29 + ] },
30 + { id: 'shapes', label: 'Shapes', tools: [
31 + { id: 'rect', label: 'Rectangle', key: 'R' },
32 + { id: 'ellipse', label: 'Ellipse', key: 'E' },
33 + { id: 'triangle', label: 'Triangle' },
34 + { id: 'polyline', label: 'Polyline' },
35 + { id: 'brush', label: 'Brush', key: 'B' },
36 + { id: 'arrow', label: 'Arrow', key: 'A' },
37 + { id: 'arrow-marker', label: 'Arrow marker' },
38 + ] },
39 + { id: 'text', label: 'Text & notes', tools: [
40 + { id: 'text', label: 'Text', key: 'X' },
41 + { id: 'callout', label: 'Callout' },
42 + { id: 'price-label', label: 'Price label' },
43 + { id: 'flag', label: 'Flag' },
44 + ] },
45 + { id: 'measure', label: 'Measure & positions', tools: [
46 + { id: 'measure', label: 'Measure', key: 'M' },
47 + { id: 'price-range', label: 'Price range' },
48 + { id: 'date-range', label: 'Date range' },
49 + { id: 'date-price-range', label: 'Date & price range' },
50 + { id: 'vertical-range', label: 'Vertical range' },
51 + { id: 'long-position', label: 'Long position', key: 'L' },
52 + { id: 'short-position', label: 'Short position', key: 'S' },
53 + ] },
54 + { id: 'patterns', label: 'Patterns', tools: [
55 + { id: 'elliott-impulse', label: 'Elliott impulse wave (12345)' },
56 + { id: 'elliott-correction', label: 'Elliott correction wave (ABC)' },
57 + { id: 'xabcd', label: 'XABCD pattern' },
58 + { id: 'head-shoulders', label: 'Head and shoulders' },
59 + ] },
60 +]
61 +
62 +const supported = new Set(Array.isArray(DRAWING_TOOLS) ? DRAWING_TOOLS : [])
63 +/** True when the loaded engine implements the tool. */
64 +export const toolSupported = id => supported.has(id)
65 +
66 +/** Groups filtered to the tools the engine implements (empty groups dropped). */
67 +export function availableGroups() {
68 + return GROUPS.map(g => ({ ...g, tools: g.tools.filter(t => toolSupported(t.id)) })).filter(g => g.tools.length)
69 +}
70 +
71 +export const ALL_TOOLS = GROUPS.flatMap(g => g.tools.map(t => ({ ...t, group: g.id })))
72 +export const toolDef = id => ALL_TOOLS.find(t => t.id === id) || null
73 +export const toolGroup = id => toolDef(id)?.group || null
74 +/** key (lower-case) → tool id, for the supported tools only. */
75 +export const TOOL_KEYS = Object.fromEntries(ALL_TOOLS.filter(t => t.key && toolSupported(t.id)).map(t => [t.key.toLowerCase(), t.id]))
76 +
77 +export const LINE_STYLES = [['solid', 'Solid'], ['dashed', 'Dashed'], ['dotted', 'Dotted']]
78 +export const LINE_WIDTHS = [1, 1.5, 2, 3, 4]
79 +export const DRAW_COLORS = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#9085e9', '#e66767', '#e8ebf1', '#8f98a8']
modified hfmarketdata/web/src/pages/charts/icons.jsx +72 −7
@@ -4,19 +4,50 @@ import React from 'react'
4 4 const I = ({ children, ...p }) => (
5 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 6 )
7 +const Dot = ({ x, y }) => <circle cx={x} cy={y} r="1.4" fill="currentColor" stroke="none" />
7 8
8 9 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>
10 +export const TrendlineIcon = p => <I {...p}><path d="M3.5 15.5 16.5 4.5" /><Dot x={3.5} y={15.5} /><Dot x={16.5} y={4.5} /></I>
11 +export const RayIcon = p => <I {...p}><path d="M4 16 17 3M14 3h3v3" /><Dot x={4} y={16} /></I>
12 +export const ExtendedIcon = p => <I {...p}><path d="M2 17 18 3" /><Dot x={7} y={12.6} /><Dot x={13} y={7.4} /></I>
13 +export const HRayIcon = p => <I {...p}><path d="M5 10h13M15 7l3 3-3 3" /><Dot x={5} y={10} /></I>
11 14 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 15 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>
16 +export const CrossIcon = p => <I {...p}><path d="M2.5 10h15M10 2.5v15" /></I>
18 17 export const ChannelIcon = p => <I {...p}><path d="M3 12 13 3M7 17 17 8" /></I>
18 +export const Channel3Icon = p => <I {...p}><path d="M3 12 13 3M7 17 17 8" /><Dot x={3} y={12} /><Dot x={13} y={3} /><Dot x={7} y={17} /></I>
19 +export const RegressionIcon = p => <I {...p}><path d="M3 14 17 6" /><path d="M3 10 17 2M3 18l14-8" strokeDasharray="2 2" /></I>
20 +export const PitchforkIcon = p => <I {...p}><path d="M3 16 17 4M8 8l9 4M6 12l9 5" /><Dot x={3} y={16} /></I>
21 +export const SchiffIcon = p => <I {...p}><path d="M3 16 17 4M9 9l8 3M6 13l9 5" /><Dot x={3} y={16} /><Dot x={9} y={9} /></I>
22 +export const FibIcon = p => <I {...p}><path d="M3 4h14M3 8h14M3 11h14M3 13h14M3 16h14" /></I>
23 +export const FibExtIcon = p => <I {...p}><path d="M3 5h14M3 9h14M3 15h14" /><path d="M3 2h14" strokeDasharray="2 2" /><path d="M10 9v6" /></I>
24 +export const FibTimeIcon = p => <I {...p}><path d="M3 3v14M6 3v14M8 3v14M11 3v14M16 3v14" /></I>
25 +export const FibFanIcon = p => <I {...p}><path d="M3 17 17 3M3 17l14-7M3 17l7-14" /><Dot x={3} y={17} /></I>
26 +export const FibArcsIcon = p => <I {...p}><path d="M3 17a14 14 0 0 1 14-14M3 17a9 9 0 0 1 9-9M3 17a5 5 0 0 1 5-5" /><Dot x={3} y={17} /></I>
27 +export const GannFanIcon = p => <I {...p}><path d="M3 17 17 3M3 17 17 10M3 17l7-14M3 17l14-3M3 17 6 3" /></I>
28 +export const GannBoxIcon = p => <I {...p}><rect x="3" y="4" width="14" height="12" /><path d="M3 16 17 4M3 10h14M10 4v12" /></I>
29 +export const RectIcon = p => <I {...p}><rect x="3.5" y="5" width="13" height="10" rx="1" /></I>
30 +export const EllipseIcon = p => <I {...p}><ellipse cx="10" cy="10" rx="7" ry="5" /></I>
31 +export const TriangleIcon = p => <I {...p}><path d="M10 4l7 12H3z" /></I>
32 +export const PolylineIcon = p => <I {...p}><path d="M3 15 7 6l4 7 3-9 3 8" /><Dot x={3} y={15} /><Dot x={7} y={6} /><Dot x={11} y={13} /><Dot x={14} y={4} /></I>
19 33 export const BrushIcon = p => <I {...p}><path d="M3 16c3 0 3-3 5-4s3 1 5-1 3-5 4-7" /></I>
34 +export const ArrowIcon = p => <I {...p}><path d="M4 16 16 4M9 4h7v7" /></I>
35 +export const ArrowMarkerIcon = p => <I {...p}><path d="M10 3v12M5 10l5 5 5-5" /></I>
36 +export const TextIcon = p => <I {...p}><path d="M4 5h12M10 5v11M7 16h6" /></I>
37 +export const CalloutIcon = p => <I {...p}><path d="M3 4h14v8h-7l-3 4v-4H3z" /></I>
38 +export const PriceLabelIcon = p => <I {...p}><path d="M3 7h9l4 3-4 3H3z" /><path d="M6 10h4" /></I>
39 +export const FlagIcon = p => <I {...p}><path d="M5 17V3M5 4h10l-2 3 2 3H5" /></I>
40 +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>
41 +export const PriceRangeIcon = p => <I {...p}><path d="M10 3v14M6 3h8M6 17h8M7 7l3-3 3 3M7 13l3 3 3-3" /></I>
42 +export const DateRangeIcon = p => <I {...p}><path d="M3 10h14M3 6v8M17 6v8M7 7l-3 3 3 3M13 7l3 3-3 3" /></I>
43 +export const DatePriceRangeIcon = p => <I {...p}><rect x="3" y="4" width="14" height="12" strokeDasharray="2 2" /><path d="M3 16 17 4" /></I>
44 +export const VerticalRangeIcon = p => <I {...p}><path d="M6 3v14M14 3v14" /><path d="M6 10h8" strokeDasharray="2 2" /></I>
45 +export const LongIcon = p => <I {...p}><rect x="3" y="4" width="14" height="6" fill="currentColor" fillOpacity=".25" stroke="none" /><rect x="3" y="10" width="14" height="6" fill="currentColor" fillOpacity=".08" stroke="none" /><path d="M3 10h14M3 4h14M3 16h14" /><path d="M10 13.5v-7M8 8.5l2-2 2 2" /></I>
46 +export const ShortIcon = p => <I {...p}><rect x="3" y="10" width="14" height="6" fill="currentColor" fillOpacity=".25" stroke="none" /><rect x="3" y="4" width="14" height="6" fill="currentColor" fillOpacity=".08" stroke="none" /><path d="M3 10h14M3 4h14M3 16h14" /><path d="M10 6.5v7M8 11.5l2 2 2-2" /></I>
47 +export const ElliottIcon = p => <I {...p}><path d="M2 16 5 9l3 4 3-9 3 5 2-4" /><path d="M4.5 6h1M8 15h1M11 2h1M13.5 7h1" strokeWidth="1" /></I>
48 +export const ElliottCorrIcon = p => <I {...p}><path d="M3 5 8 13l3-4 6 8" /><Dot x={8} y={13} /><Dot x={11} y={9} /></I>
49 +export const XabcdIcon = p => <I {...p}><path d="M2 14 6 4l4 9 4-8 3 12" /><path d="M2 14 10 13M6 4l8 1" strokeDasharray="2 2" strokeWidth="1" /></I>
50 +export const HeadShouldersIcon = p => <I {...p}><path d="M2 15 5 9l2 4 3-10 3 10 2-4 3 6" /><path d="M2 15h16" strokeDasharray="2 2" strokeWidth="1" /></I>
20 51 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 52 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 53 export const TrashIcon = p => <I {...p}><path d="M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10M9 9v4M11 9v4" /></I>
@@ -43,5 +74,39 @@ export const DotsIcon = p => <I {...p}><circle cx="5" cy="10" r="1.4" fill="curr
43 74 export const PencilIcon = p => <I {...p}><path d="M3 17h4l9-9-4-4-9 9zM10 6l4 4" /></I>
44 75 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 76 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>
77 +export const LockIcon = p => <I {...p}><rect x="4" y="9" width="12" height="8" rx="1.5" /><path d="M7 9V6.5a3 3 0 0 1 6 0V9" /></I>
78 +export const UnlockIcon = p => <I {...p}><rect x="4" y="9" width="12" height="8" rx="1.5" /><path d="M7 9V6.5a3 3 0 0 1 5.6-1.5" /></I>
79 +export const EyeIcon = p => <I {...p}><path d="M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z" /><circle cx="10" cy="10" r="2.2" /></I>
80 +export const EyeOffIcon = p => <I {...p}><path d="M3 3l14 14M8.5 8.6A2.2 2.2 0 0 0 11.4 11.5M6.3 6.4C3.6 7.9 2 10 2 10s3 5 8 5c1.4 0 2.6-.4 3.7-.9M8.6 5.2A8.6 8.6 0 0 1 10 5c5 0 8 5 8 5s-.8 1.4-2.3 2.7" /></I>
81 +export const PlayIcon = p => <I {...p}><path d="M6 4l10 6-10 6z" fill="currentColor" stroke="none" /></I>
82 +export const PauseIcon = p => <I {...p}><path d="M6 4h3v12H6zM11 4h3v12h-3z" fill="currentColor" stroke="none" /></I>
83 +export const StepBackIcon = p => <I {...p}><path d="M5 4v12" /><path d="M16 4 7 10l9 6z" fill="currentColor" stroke="none" /></I>
84 +export const StepFwdIcon = p => <I {...p}><path d="M15 4v12" /><path d="M4 4l9 6-9 6z" fill="currentColor" stroke="none" /></I>
85 +export const ReplayIcon = p => <I {...p}><path d="M4 10a6 6 0 1 1 1.8 4.3" /><path d="M4 6v4h4" /><path d="M9 8l4 2-4 2z" fill="currentColor" stroke="none" /></I>
86 +export const TargetIcon = p => <I {...p}><circle cx="10" cy="10" r="6" /><path d="M10 2v3M10 15v3M2 10h3M15 10h3" /></I>
87 +export const Layout1Icon = p => <I {...p}><rect x="3" y="4" width="14" height="12" rx="1" /></I>
88 +export const Layout2hIcon = p => <I {...p}><rect x="3" y="4" width="14" height="12" rx="1" /><path d="M10 4v12" /></I>
89 +export const Layout2vIcon = p => <I {...p}><rect x="3" y="4" width="14" height="12" rx="1" /><path d="M3 10h14" /></I>
90 +export const Layout4Icon = p => <I {...p}><rect x="3" y="4" width="14" height="12" rx="1" /><path d="M10 4v12M3 10h14" /></I>
91 +export const TemplateIcon = p => <I {...p}><path d="M4 3h9l3 3v11H4z" /><path d="M7 9h6M7 12h6M7 15h4" /></I>
92 +export const StarIcon = ({ filled = false, ...p }) => <I {...p}><path d="M10 2.8l2.2 4.6 5 .7-3.6 3.5.9 5L10 14.2l-4.5 2.4.9-5L2.8 8.1l5-.7z" fill={filled ? 'currentColor' : 'none'} /></I>
93 +export const KeyboardIcon = p => <I {...p}><rect x="2" y="5" width="16" height="10" rx="1.5" /><path d="M5 8h1M8 8h1M11 8h1M14 8h1M5 11h1M8 11h4M14 11h1" /></I>
94 +export const DragIcon = p => <I {...p}><path d="M7 5h1M12 5h1M7 10h1M12 10h1M7 15h1M12 15h1" strokeWidth="2.2" /></I>
95 +export const UpIcon = p => <I {...p}><path d="M5 12l5-5 5 5" /></I>
96 +export const DownIcon = p => <I {...p}><path d="M5 8l5 5 5-5" /></I>
97 +export const CopyIcon = p => <I {...p}><rect x="7" y="7" width="10" height="10" rx="1.5" /><path d="M13 7V4.5A1.5 1.5 0 0 0 11.5 3h-7A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13H7" /></I>
98 +export const BellIcon = p => <I {...p}><path d="M5 14V9a5 5 0 0 1 10 0v5l1.5 2H3.5z" /><path d="M8.5 17.5a1.5 1.5 0 0 0 3 0" /></I>
99 +export const ResetIcon = p => <I {...p}><path d="M4 10a6 6 0 1 0 2-4.5" /><path d="M4 3v4h4" /></I>
100 +export const GridIcon = p => <I {...p}><path d="M3 7h14M3 13h14M7 3v14M13 3v14" /></I>
46 101
47 102 export const SERIES_ICONS = { candles: CandlesIcon, hollow: HollowIcon, ohlc: OhlcIcon, line: LineIcon, area: AreaIcon, baseline: BaselineIcon, heikin: HeikinIcon, columns: ColumnsIcon, hlc: HlcIcon }
103 +export const LAYOUT_ICONS = { '1': Layout1Icon, '2h': Layout2hIcon, '2v': Layout2vIcon, '4': Layout4Icon }
104 +export const TOOL_ICONS = {
105 + trendline: TrendlineIcon, ray: RayIcon, extended: ExtendedIcon, hray: HRayIcon, hline: HLineIcon, vline: VLineIcon, cross: CrossIcon,
106 + channel: ChannelIcon, 'channel-parallel': Channel3Icon, regression: RegressionIcon, pitchfork: PitchforkIcon, schiff: SchiffIcon,
107 + fib: FibIcon, 'fib-extension': FibExtIcon, 'fib-timezones': FibTimeIcon, 'fib-fan': FibFanIcon, 'fib-arcs': FibArcsIcon, 'gann-fan': GannFanIcon, 'gann-box': GannBoxIcon,
108 + rect: RectIcon, ellipse: EllipseIcon, triangle: TriangleIcon, polyline: PolylineIcon, brush: BrushIcon, arrow: ArrowIcon, 'arrow-marker': ArrowMarkerIcon,
109 + text: TextIcon, callout: CalloutIcon, 'price-label': PriceLabelIcon, flag: FlagIcon,
110 + measure: MeasureIcon, 'price-range': PriceRangeIcon, 'date-range': DateRangeIcon, 'date-price-range': DatePriceRangeIcon, 'vertical-range': VerticalRangeIcon, 'long-position': LongIcon, 'short-position': ShortIcon,
111 + 'elliott-impulse': ElliottIcon, 'elliott-correction': ElliottCorrIcon, xabcd: XabcdIcon, 'head-shoulders': HeadShouldersIcon,
112 +}
modified hfmarketdata/web/src/pages/charts/indicators.js +150 −34
@@ -1,56 +1,172 @@
1 −// Indicator catalog for the /charts page: id, label, default pane, parameters (ordered — the URL form is
2 −// `type:p1:p2…`) and the output keys shown in the legend (matching the engine's `values` / crosshair keys).
3 −export const INDICATORS = [
4 − { type: 'sma', label: 'Moving average (SMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
5 − { type: 'ema', label: 'Exponential MA (EMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
6 − { type: 'wma', label: 'Weighted MA (WMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
7 − { type: 'vwap', label: 'VWAP', pane: 'main', params: [], outputs: ['value'] },
8 − { type: 'bollinger', label: 'Bollinger Bands', pane: 'main', params: [['length', 20, 2, 500], ['mult', 2, 0.1, 10]], outputs: ['upper', 'middle', 'lower'] },
9 − { type: 'keltner', label: 'Keltner Channels', pane: 'main', params: [['length', 20, 2, 500], ['mult', 2, 0.1, 10]], outputs: ['upper', 'middle', 'lower'] },
10 − { type: 'donchian', label: 'Donchian Channels', pane: 'main', params: [['length', 20, 2, 500]], outputs: ['upper', 'middle', 'lower'] },
11 − { type: 'supertrend', label: 'Supertrend', pane: 'main', params: [['length', 10, 1, 200], ['mult', 3, 0.1, 10]], outputs: ['value'] },
12 − { type: 'ichimoku', label: 'Ichimoku Cloud', pane: 'main', params: [['conversion', 9, 1, 200], ['base', 26, 1, 400], ['span', 52, 1, 800]], outputs: ['conversion', 'base', 'spanA', 'spanB'] },
13 − { type: 'rsi', label: 'RSI', pane: 'new', params: [['length', 14, 2, 500]], outputs: ['value'] },
14 − { type: 'macd', label: 'MACD', pane: 'new', params: [['fast', 12, 1, 200], ['slow', 26, 2, 400], ['signal', 9, 1, 200]], outputs: ['macd', 'signal', 'histogram'] },
15 − { type: 'stoch', label: 'Stochastic', pane: 'new', params: [['k', 14, 1, 200], ['d', 3, 1, 100], ['smooth', 3, 1, 100]], outputs: ['k', 'd'] },
16 − { type: 'atr', label: 'ATR', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['value'] },
17 − { type: 'obv', label: 'On-balance volume', pane: 'new', params: [], outputs: ['value'] },
18 − { type: 'adx', label: 'ADX', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['adx', 'plusDI', 'minusDI'] },
19 − { type: 'cci', label: 'CCI', pane: 'new', params: [['length', 20, 1, 500]], outputs: ['value'] },
20 − { type: 'mfi', label: 'Money flow index', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['value'] },
21 − { type: 'volume-ma', label: 'Volume MA', pane: 'new', params: [['length', 20, 1, 500]], outputs: ['value'] },
1 +// Indicator catalog for the /charts page.
2 +//
3 +// Two sources, merged into one registry keyed by id:
4 +// * the LOCAL catalog below — the 19 indicators of the v1 engine with categories, descriptions and typed inputs;
5 +// * the engine's own `chart.listIndicators()` (v2, additive) — `{ id, name, category, inputs, plots }` — registered
6 +// at runtime with `setEngineCatalog()`. Engine entries win on inputs/plots (the engine knows its own params), the
7 +// local entry contributes the description / category when the engine has none.
8 +//
9 +// Every definition is normalised to the v2 shape:
10 +// { id, name, category, description, pane: 'main' | 'new',
11 +// inputs: [{ name, type: 'int' | 'float' | 'select' | 'source' | 'bool', min, max, step, options, default }],
12 +// plots: [{ key, type: 'line' | 'histogram' | 'band' | 'cloud', color, lineWidth, style }] }
13 +// plus the legacy fields the page has always used (`type`, `label`, `params` [[key, default, min, max]], `outputs`).
14 +// URL form: `type:p1:p2…` — the NUMERIC inputs, positional, in catalog order (`sma:20`, `macd:12:26:9`).
15 +
16 +export const CATEGORIES = ['Favorites', 'Trend', 'Momentum', 'Volatility', 'Volume', 'Bands', 'Support/Resistance', 'Statistics', 'Other']
17 +export const SOURCES = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4']
18 +
19 +const int = (name, def, min, max, step = 1) => ({ name, type: 'int', min, max, step, default: def })
20 +const num = (name, def, min, max, step = 0.1) => ({ name, type: 'float', min, max, step, default: def })
21 +const src = (name = 'source', def = 'close') => ({ name, type: 'source', options: SOURCES, default: def })
22 +const line = (key, color, extra = {}) => ({ key, type: 'line', color, lineWidth: 1, style: 'solid', ...extra })
23 +
24 +const LOCAL = [
25 + { id: 'sma', name: 'Moving average (SMA)', category: 'Trend', pane: 'main', description: 'Arithmetic mean of the last n closes — the baseline trend filter.', inputs: [int('length', 20, 1, 500), src()], plots: [line('sma', 0)] },
26 + { id: 'ema', name: 'Exponential MA (EMA)', category: 'Trend', pane: 'main', description: 'Weighted towards recent prices; reacts faster than the SMA.', inputs: [int('length', 20, 1, 500), src()], plots: [line('ema', 1)] },
27 + { id: 'wma', name: 'Weighted MA (WMA)', category: 'Trend', pane: 'main', description: 'Linearly weighted average: the newest bar counts n times the oldest.', inputs: [int('length', 20, 1, 500), src()], plots: [line('wma', 2)] },
28 + { id: 'vwap', name: 'VWAP', category: 'Volume', pane: 'main', description: 'Volume-weighted average price, anchored on each session (intraday).', inputs: [{ name: 'anchor', type: 'select', options: ['session', 'week', 'month'], default: 'session' }], plots: [line('vwap', 3)] },
29 + { id: 'bollinger', name: 'Bollinger Bands', category: 'Bands', pane: 'main', description: 'SMA ± k standard deviations; the width tracks volatility.', inputs: [int('length', 20, 2, 500), num('mult', 2, 0.1, 10), src()], plots: [line('upper', 0), line('middle', 0, { style: 'dashed' }), line('lower', 0)] },
30 + { id: 'keltner', name: 'Keltner Channels', category: 'Bands', pane: 'main', description: 'EMA ± k × ATR; smoother than Bollinger, no standard deviation.', inputs: [int('length', 20, 2, 500), num('mult', 2, 0.1, 10), int('atrLength', 10, 1, 200)], plots: [line('upper', 6), line('middle', 6, { style: 'dashed' }), line('lower', 6)] },
31 + { id: 'donchian', name: 'Donchian Channels', category: 'Bands', pane: 'main', description: 'Highest high and lowest low of the last n bars (breakout channel).', inputs: [int('length', 20, 2, 500)], plots: [line('upper', 3), line('middle', 3, { style: 'dashed' }), line('lower', 3)] },
32 + { id: 'supertrend', name: 'Supertrend', category: 'Trend', pane: 'main', description: 'ATR-based trailing stop that flips with the trend.', inputs: [int('length', 10, 1, 200), num('mult', 3, 0.1, 10)], plots: [line('up', 'up', { lineWidth: 1.5 }), line('down', 'down', { lineWidth: 1.5 })] },
33 + { id: 'ichimoku', name: 'Ichimoku Cloud', category: 'Trend', pane: 'main', description: 'Tenkan, Kijun, Senkou A/B cloud and Chikou — trend, support and momentum in one overlay.', inputs: [int('conversion', 9, 1, 200), int('base', 26, 1, 400), int('spanB', 52, 1, 800), int('displacement', 26, 0, 200)], plots: [line('tenkan', 0), line('kijun', 1), line('senkouA', 'up', { lineWidth: 0.75 }), line('senkouB', 'down', { lineWidth: 0.75 }), line('chikou', 4)] },
34 + { id: 'rsi', name: 'RSI', category: 'Momentum', pane: 'new', description: 'Relative strength index, 0–100; 30 / 70 are the classic oversold / overbought levels.', inputs: [int('length', 14, 2, 500), src()], plots: [line('rsi', 6)], levels: [30, 70] },
35 + { id: 'macd', name: 'MACD', category: 'Momentum', pane: 'new', description: 'Difference of two EMAs, its signal line and the histogram between them.', inputs: [int('fast', 12, 1, 200), int('slow', 26, 2, 400), int('signal', 9, 1, 200), src()], plots: [{ key: 'hist', type: 'histogram', color: 'updown' }, line('macd', 0), line('signal', 1)], levels: [0] },
36 + { id: 'stoch', name: 'Stochastic', category: 'Momentum', pane: 'new', description: 'Where the close sits in the recent high–low range (%K) and its smoothing (%D).', inputs: [int('k', 14, 1, 200), int('d', 3, 1, 100), int('smooth', 3, 1, 100)], plots: [line('k', 0), line('d', 1)], levels: [20, 80] },
37 + { id: 'atr', name: 'ATR', category: 'Volatility', pane: 'new', description: 'Average true range — the typical bar-to-bar movement in price units.', inputs: [int('length', 14, 1, 500)], plots: [line('atr', 1)] },
38 + { id: 'obv', name: 'On-balance volume', category: 'Volume', pane: 'new', description: 'Cumulative volume signed by the direction of each bar.', inputs: [], plots: [line('obv', 0)] },
39 + { id: 'adx', name: 'ADX', category: 'Trend', pane: 'new', description: 'Trend strength (ADX) with the +DI / −DI directional lines.', inputs: [int('length', 14, 1, 500)], plots: [line('adx', 0, { lineWidth: 1.5 }), line('plusDI', 'up'), line('minusDI', 'down')], levels: [25] },
40 + { id: 'cci', name: 'CCI', category: 'Momentum', pane: 'new', description: 'Commodity channel index — deviation of the typical price from its mean.', inputs: [int('length', 20, 1, 500)], plots: [line('cci', 4)], levels: [-100, 100] },
41 + { id: 'mfi', name: 'Money flow index', category: 'Volume', pane: 'new', description: 'Volume-weighted RSI, 0–100.', inputs: [int('length', 14, 1, 500)], plots: [line('mfi', 2)], levels: [20, 80] },
42 + { id: 'volume-ma', name: 'Volume + MA', category: 'Volume', pane: 'new', description: 'Volume histogram in its own pane with a moving average.', inputs: [int('length', 20, 1, 500)], plots: [{ key: 'volume', type: 'histogram', color: 'volume' }, line('ma', 3)] },
22 43 ]
23 44
24 −export const indicatorDef = type => INDICATORS.find(i => i.type === type) || null
45 +const SHORT = { sma: 'SMA', ema: 'EMA', wma: 'WMA', vwap: 'VWAP', bollinger: 'BB', keltner: 'KC', donchian: 'DC', supertrend: 'ST', ichimoku: 'Ichimoku', rsi: 'RSI', macd: 'MACD', stoch: 'Stoch', atr: 'ATR', obv: 'OBV', adx: 'ADX', cci: 'CCI', mfi: 'MFI', 'volume-ma': 'Vol MA' }
46 +
47 +const isNumeric = inp => inp.type === 'int' || inp.type === 'float' || (inp.type == null && typeof inp.default === 'number')
48 +
49 +/** Normalise any definition (local or engine) to the shared shape + legacy fields. */
50 +function normalize(def, local) {
51 + const id = def.id || def.type
52 + const inputs = (def.inputs || []).map(i => ({ ...i, type: i.type || (typeof i.default === 'number' ? (Number.isInteger(i.default) && (i.step == null || Number.isInteger(i.step)) ? 'int' : 'float') : typeof i.default === 'boolean' ? 'bool' : i.options ? 'select' : 'float') }))
53 + const plots = (def.plots || []).filter(p => p.key)
54 + const category = CATEGORIES.includes(def.category) ? def.category : (local?.category || guessCategory(def.category) || 'Other')
55 + const pane = def.pane || local?.pane || (category === 'Trend' || category === 'Bands' || category === 'Support/Resistance' ? 'main' : 'new')
56 + return {
57 + ...def, id, type: id, inputs, plots, category, pane,
58 + name: def.name || local?.name || id, label: def.name || local?.name || id,
59 + short: def.short || local?.short || SHORT[id] || (def.name || id).replace(/\s*\(.*\)$/, '').toUpperCase().slice(0, 10),
60 + description: def.description || local?.description || '',
61 + levels: def.levels || local?.levels,
62 + params: inputs.filter(isNumeric).map(i => [i.name, i.default, i.min ?? -Infinity, i.max ?? Infinity]),
63 + outputs: plots.map(p => p.key),
64 + }
65 +}
66 +
67 +function guessCategory(c) {
68 + if (!c) return null
69 + const k = String(c).toLowerCase()
70 + if (k.includes('trend') || k.includes('average')) return 'Trend'
71 + if (k.includes('momentum') || k.includes('oscill')) return 'Momentum'
72 + if (k.includes('volat')) return 'Volatility'
73 + if (k.includes('volume')) return 'Volume'
74 + if (k.includes('band') || k.includes('channel')) return 'Bands'
75 + if (k.includes('support') || k.includes('pivot') || k.includes('resist')) return 'Support/Resistance'
76 + if (k.includes('stat') || k.includes('regress') || k.includes('correl')) return 'Statistics'
77 + return null
78 +}
79 +
80 +let registry = new Map(LOCAL.map(d => [d.id, normalize(d)]))
81 +let engineIds = null
82 +
83 +/** Register the engine's catalog (v2 `listIndicators()`); safe to call more than once. */
84 +export function setEngineCatalog(list) {
85 + if (!Array.isArray(list) || !list.length) return
86 + const next = new Map()
87 + const localById = new Map(LOCAL.map(d => [d.id, d]))
88 + for (const d of list) { if (d && (d.id || d.type)) next.set(d.id || d.type, normalize(d, localById.get(d.id || d.type))) }
89 + // local entries the engine did not list stay available only if the engine is not authoritative (v2 lists everything)
90 + registry = next
91 + engineIds = new Set(next.keys())
92 +}
93 +export const hasEngineCatalog = () => engineIds != null
94 +
95 +/** All definitions, catalog order (engine order when registered). */
96 +export function catalog() { return Array.from(registry.values()) }
97 +/** The local (v1 engine) catalog as a plain array — new code should call `catalog()` to see the engine's list too. */
98 +export const INDICATORS = LOCAL.map(d => normalize(d))
99 +
100 +export const indicatorDef = type => registry.get(type) || null
25 101
26 102 export function defaultParams(def) {
27 − return Object.fromEntries(def.params.map(([k, v]) => [k, v]))
103 + return Object.fromEntries((def?.inputs || []).map(i => [i.name, i.default]))
28 104 }
29 105
30 −/** `sma:20` | `macd:12:26:9` | `vwap` → { type, params, pane } (null when unknown). */
106 +const clampInput = (inp, v) => {
107 + if (inp.type === 'int' || inp.type === 'float') {
108 + const n = Number(v)
109 + if (!Number.isFinite(n)) return inp.default
110 + const c = Math.min(inp.max ?? Infinity, Math.max(inp.min ?? -Infinity, n))
111 + return inp.type === 'int' ? Math.round(c) : c
112 + }
113 + if (inp.type === 'bool') return v === true || v === 'true' || v === '1'
114 + if (inp.options && !inp.options.includes(v)) return inp.default
115 + return v
116 +}
117 +
118 +/** Params sanitised against the definition (unknown keys kept: the engine may know more than we do). */
119 +export function sanitizeParams(def, params) {
120 + const out = { ...(params || {}) }
121 + for (const inp of def?.inputs || []) out[inp.name] = clampInput(inp, out[inp.name] ?? inp.default)
122 + return out
123 +}
124 +
125 +/** `sma:20` | `macd:12:26:9` | `vwap` → { type, params, pane }. Unknown types are kept with their positional values
126 + * (`vals`) and resolved later by `resolveIndicator` once the engine catalog is registered. */
31 127 export function indicatorFromSpec(spec) {
32 128 const [type, ...vals] = spec.split(':')
129 + if (!type) return null
33 130 const def = indicatorDef(type)
34 − if (!def) return null
131 + if (!def) return { type, params: {}, vals }
132 + return { type, params: paramsFromVals(def, vals), pane: def.pane }
133 +}
134 +
135 +function paramsFromVals(def, vals) {
35 136 const params = defaultParams(def)
36 − def.params.forEach(([k, , min, max], i) => {
37 − const n = Number(vals[i])
38 − if (vals[i] != null && Number.isFinite(n)) params[k] = Math.min(max, Math.max(min, n))
39 − })
40 − return { type, params, pane: def.pane }
137 + def.inputs.filter(isNumeric).forEach((inp, i) => { if (vals[i] != null && vals[i] !== '') params[inp.name] = clampInput(inp, vals[i]) })
138 + return params
139 +}
140 +
141 +/** Fill pane / params of an indicator whose definition was unknown at parse time. Returns null when still unknown. */
142 +export function resolveIndicator(ind) {
143 + const def = indicatorDef(ind.type)
144 + if (!def) return null
145 + if (ind.vals) return { ...ind, vals: undefined, params: paramsFromVals(def, ind.vals), pane: ind.pane || def.pane }
146 + return { ...ind, pane: ind.pane || def.pane, params: sanitizeParams(def, ind.params) }
41 147 }
42 148
43 149 export function indicatorToSpec(ind) {
44 150 const def = indicatorDef(ind.type)
45 − if (!def) return ind.type
46 − const vals = def.params.map(([k]) => ind.params?.[k]).filter(v => v != null)
151 + if (!def) return ind.vals?.length ? [ind.type, ...ind.vals].join(':') : ind.type
152 + const vals = def.inputs.filter(isNumeric).map(i => ind.params?.[i.name]).filter(v => v != null)
47 153 return [ind.type, ...vals].join(':')
48 154 }
49 155
50 156 /** Short label for the legend: "SMA 20", "MACD 12 26 9". */
51 157 export function indicatorShortLabel(ind) {
52 158 const def = indicatorDef(ind.type)
53 − const name = ind.type === 'volume-ma' ? 'Vol MA' : ind.type.toUpperCase()
54 − const vals = def ? def.params.map(([k]) => ind.params?.[k]).filter(v => v != null) : []
159 + const name = def?.short || ind.type.toUpperCase()
160 + const vals = def ? def.inputs.filter(isNumeric).map(i => ind.params?.[i.name]).filter(v => v != null) : (ind.vals || [])
55 161 return vals.length ? `${name} ${vals.join(' ')}` : name
56 162 }
163 +
164 +/** Search + category filter over the catalog. */
165 +export function searchCatalog(q, category, favorites = []) {
166 + const s = (q || '').trim().toLowerCase()
167 + let list = catalog()
168 + if (category === 'Favorites') list = list.filter(d => favorites.includes(d.id))
169 + else if (category && category !== 'All') list = list.filter(d => d.category === category)
170 + if (s) list = list.filter(d => d.name.toLowerCase().includes(s) || d.id.includes(s) || (d.short || '').toLowerCase().includes(s) || d.description.toLowerCase().includes(s))
171 + return list
172 +}
modified hfmarketdata/web/src/pages/charts/panels.jsx +72 −25
@@ -1,14 +1,13 @@
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'
1 +// Content panels shared by the desktop toolbar menus and the mobile bottom sheet: series type, quick indicators,
2 +// compare, adjustment, scale, settings, layout, templates. They render MenuItems (menuitem / menuitemradio) so they
3 +// behave inside a <Menu>, and plain lists inside the sheet (the Menu context defaults to a no-op close).
4 +import React, { useRef, useState } from 'react'
6 5 import { Input } from '../../components/Field.jsx'
7 6 import { MenuItem, MenuSection } from './Menu.jsx'
8 −import { INDICATORS } from './indicators.js'
9 −import { SERIES_TYPES } from '../../charts/data/state.js'
7 +import { catalog, searchCatalog } from './indicators.js'
8 +import { LAYOUTS, SERIES_TYPES } from '../../charts/data/state.js'
10 9 import { ADJUSTMENTS } from '../../charts/data/symbols.js'
11 −import { SERIES_ICONS } from './icons.jsx'
10 +import { LAYOUT_ICONS, SERIES_ICONS, StarIcon } from './icons.jsx'
12 11 import SymbolSearch from './SymbolSearch.jsx'
13 12
14 13 export function SeriesTypePanel({ value, onChange }) {
@@ -19,32 +18,38 @@ export function SeriesTypePanel({ value, onChange }) {
19 18 )
20 19 }
21 20
22 −export function IndicatorsPanel({ onAdd, active = [] }) {
21 +/** Quick indicator picker (toolbar menu / mobile sheet); the full library is the ⌘I dialog. */
22 +export function IndicatorsPanel({ onAdd, active = [], favorites = [], onOpenLibrary }) {
23 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')
24 + const list = q ? searchCatalog(q, 'All') : catalog()
25 + const favs = !q ? list.filter(i => favorites.includes(i.id)) : []
26 + const overlays = list.filter(i => i.pane === 'main' && !favs.includes(i)), panes = list.filter(i => i.pane !== 'main' && !favs.includes(i))
27 + const count = type => active.filter(a => a === type).length
28 + const item = i => <MenuItem key={i.id} onSelect={() => onAdd(i.id)} hint={count(i.id) ? `×${count(i.id)}` : undefined} testId={`ch-ind-${i.id}`}>{i.name}<span className="ch-item-sub">{i.category}</span></MenuItem>
26 29 return (
27 30 <div className="ch-indpanel">
28 31 <div className="ch-pop-search">
29 32 <Input type="search" placeholder="Search indicators…" value={q} onChange={e => setQ(e.target.value)} aria-label="Search indicators" data-testid="ch-ind-search" />
30 33 </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>}
34 + {onOpenLibrary && <MenuSection><MenuItem onSelect={onOpenLibrary} icon={<StarIcon />} testId="ch-ind-open-library">Indicator library… <span className="ch-item-sub">categories, favourites, descriptions · ⌘I</span></MenuItem></MenuSection>}
35 + {favs.length > 0 && <MenuSection title="Favorites">{favs.map(item)}</MenuSection>}
36 + {overlays.length > 0 && <MenuSection title="Overlays">{overlays.map(item)}</MenuSection>}
37 + {panes.length > 0 && <MenuSection title="Oscillators (new pane)">{panes.map(item)}</MenuSection>}
33 38 {!list.length && <p className="muted small" style={{ padding: '8px 12px', margin: 0 }}>No indicator matches “{q}”.</p>}
34 39 </div>
35 40 )
36 41 }
37 42
38 −export function ComparePanel({ compares, onAdd, onRemove, apiKey }) {
43 +export function ComparePanel({ compares, onAdd, onRemove }) {
39 44 return (
40 45 <div className="ch-cmppanel">
41 46 <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>
47 + <div className="ch-pop-search"><SymbolSearch current="" onPick={onAdd} compact placeholder="Add symbol…" autoFocus id="ch-compare-input" /></div>
43 48 {compares.length ? (
44 49 <div className="ch-chips">
45 50 {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 51 </div>
47 − ) : <p className="muted small" style={{ padding: '4px 12px 8px', margin: 0 }}>Overlays another symbol normalised to % since the first visible bar.</p>}
52 + ) : <p className="muted small" style={{ padding: '4px 12px 8px', margin: 0 }}>Overlays another symbol normalised to % since the first visible bar. Older history is paginated like the main series.</p>}
48 53 </MenuSection>
49 54 </div>
50 55 )
@@ -70,24 +75,66 @@ export function ScalePanel({ scale, onScale, auto, onAuto, hasCompare }) {
70 75 )
71 76 }
72 77
73 −export function SettingsPanel({ prefs, onPrefs, volume, onVolume, apiKey, onApiKey, authenticated }) {
78 +export function SettingsPanel({ prefs, onPrefs, volume, onVolume, multi = false }) {
74 79 const set = (k, v) => onPrefs({ ...prefs, [k]: v })
75 80 return (
76 81 <div className="ch-settings">
77 82 <MenuSection title="Display">
78 83 <MenuItem checked={volume} onSelect={() => onVolume(!volume)} keepOpen testId="ch-set-volume">Volume histogram</MenuItem>
84 + <MenuItem checked={prefs.grid !== false} onSelect={() => set('grid', prefs.grid === false)} keepOpen testId="ch-set-grid">Grid lines</MenuItem>
79 85 <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 86 <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>
87 + <MenuItem checked={prefs.showDrawingBar !== false} onSelect={() => set('showDrawingBar', prefs.showDrawingBar === false)} keepOpen>Drawing toolbar</MenuItem>
82 88 <MenuItem checked={prefs.reducedMotion} onSelect={() => set('reducedMotion', !prefs.reducedMotion)} keepOpen>Reduce motion</MenuItem>
89 + <MenuItem checked={prefs.announce !== false} onSelect={() => set('announce', prefs.announce === false)} keepOpen>Announce the visible range <span className="ch-item-sub">screen readers, after pan / zoom</span></MenuItem>
90 + </MenuSection>
91 + <MenuSection title="Drawing">
92 + <MenuItem checked={prefs.magnet} onSelect={() => set('magnet', !prefs.magnet)} keepOpen>Magnet crosshair</MenuItem>
93 + <MenuItem checked={prefs.drawLock} onSelect={() => set('drawLock', !prefs.drawLock)} keepOpen>Stay in drawing mode</MenuItem>
83 94 </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>
95 + {multi && (
96 + <MenuSection title="Multi-chart sync">
97 + <MenuItem checked={!!prefs.syncSymbol} onSelect={() => set('syncSymbol', !prefs.syncSymbol)} keepOpen testId="ch-set-sync-symbol">Symbol</MenuItem>
98 + <MenuItem checked={prefs.syncCrosshair !== false} onSelect={() => set('syncCrosshair', prefs.syncCrosshair === false)} keepOpen testId="ch-set-sync-crosshair">Crosshair</MenuItem>
99 + <MenuItem checked={!!prefs.syncTime} onSelect={() => set('syncTime', !prefs.syncTime)} keepOpen testId="ch-set-sync-time">Time range</MenuItem>
100 + </MenuSection>
101 + )}
102 + </div>
103 + )
104 +}
105 +
106 +export function LayoutPanel({ value, onChange }) {
107 + return (
108 + <MenuSection title="Layout">
109 + {Object.entries(LAYOUTS).map(([id, l]) => { const Icon = LAYOUT_ICONS[id]; return <MenuItem key={id} checked={value === id} onSelect={() => onChange(id)} icon={<Icon />} testId={`ch-layout-${id}`}>{l.label}<span className="ch-item-sub">Alt {Object.keys(LAYOUTS).indexOf(id) + 1}</span></MenuItem> })}
110 + </MenuSection>
111 + )
112 +}
113 +
114 +export function TemplatesPanel({ templates, onApply, onSave, onDelete, onSetDefault, onExport, onImport }) {
115 + const [name, setName] = useState('')
116 + const fileRef = useRef(null)
117 + return (
118 + <div className="ch-tplpanel">
119 + <MenuSection title="Save current chart as template">
120 + <form className="ch-pop-search ch-tpl-save" onSubmit={e => { e.preventDefault(); if (name.trim()) { onSave(name.trim()); setName('') } }}>
121 + <Input value={name} onChange={e => setName(e.target.value)} placeholder="Template name…" aria-label="Template name" data-testid="ch-tpl-name" onKeyDown={e => e.stopPropagation()} />
122 + <button type="submit" className="btn btn-sm btn-primary" disabled={!name.trim()} data-testid="ch-tpl-save">Save</button>
123 + </form>
124 + </MenuSection>
125 + <MenuSection title={templates.length ? 'Saved templates' : 'No saved template yet'}>
126 + {templates.map(t => (
127 + <div key={t.name} className="ch-tpl-row" data-testid="ch-tpl-row">
128 + <MenuItem onSelect={() => onApply(t)} testId={`ch-tpl-apply-${t.name.replace(/\W+/g, '-')}`}>{t.name}<span className="ch-item-sub">{t.indicators?.length || 0} indicators · {t.type || 'candles'}{t.isDefault ? ' · default' : ''}</span></MenuItem>
129 + <button type="button" className={`ch-legend-x ${t.isDefault ? 'is-on' : ''}`} aria-pressed={!!t.isDefault} aria-label={t.isDefault ? `Unset ${t.name} as default template` : `Use ${t.name} as default template`} title="Default template" onClick={() => onSetDefault(t.isDefault ? null : t.name)}><StarIcon filled={!!t.isDefault} /></button>
130 + <button type="button" className="ch-legend-x" aria-label={`Delete template ${t.name}`} onClick={() => onDelete(t.name)}>×</button>
131 + </div>
132 + ))}
133 + </MenuSection>
134 + <MenuSection>
135 + <MenuItem onSelect={onExport} disabled={!templates.length} keepOpen testId="ch-tpl-export">Export JSON</MenuItem>
136 + <MenuItem onSelect={() => fileRef.current?.click()} keepOpen testId="ch-tpl-import">Import JSON…</MenuItem>
137 + <input ref={fileRef} type="file" accept="application/json,.json" className="sr-only" aria-label="Import templates" tabIndex={-1} onChange={e => { const f = e.target.files?.[0]; if (f) f.text().then(onImport); e.target.value = '' }} data-testid="ch-tpl-file" />
91 138 </MenuSection>
92 139 </div>
93 140 )
94 141