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>
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 | |