web: charts v2 intégration — setCrosshairPosition (sync multi-charts), sélection via selectDrawing, e2e alignés sur le moteur v2 (replay masqué, lignes de prix, description) ; tiroir mobile en portail body avec fond opaque de repli
6 changed files +51 −20
modified
hfmarketdata/web/e2e/charts.spec.js
+23 −14
@@ -262,7 +262,7 @@ test.describe('charts', () => { | ||
| 262 | 262 | await bar.getByTestId('ch-group-more-lines').click() |
| 263 | 263 | const fly = page.getByTestId('ch-flyout-lines') |
| 264 | 264 | await expect(fly).toBeVisible() |
| 265 | − await expect(fly.locator('[data-tool]')).toHaveCount(6) // engine v1: trendline, ray, extended, hline, vline, channel | |
| 265 | + expect(await fly.locator('[data-tool]').count()).toBeGreaterThanOrEqual(6) // v1: 6 line tools, v2: 11 | |
| 266 | 266 | await fly.locator('[data-tool="ray"]').click() |
| 267 | 267 | await expect(fly).toHaveCount(0) |
| 268 | 268 | await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('data-tool', 'ray') |
@@ -286,21 +286,29 @@ test.describe('charts', () => { | ||
| 286 | 286 | }) |
| 287 | 287 | |
| 288 | 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 | 289 | await setup(page) |
| 291 | 290 | await page.goto('/charts?debug=1') |
| 292 | − await expect(page.getByTestId('ch-props')).toBeVisible() | |
| 291 | + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) | |
| 292 | + await page.evaluate(() => { const c = window.__hfmdChart; const b = c.getData(); const d = { id: 'e2e-h1', type: 'hline', points: [{ t: b[b.length - 20].t, price: b[b.length - 20].c }] }; c.setDrawings([d]); c.selectDrawing(['e2e-h1']) }) | |
| 293 | + const props = page.getByTestId('ch-props') | |
| 294 | + await expect(props).toBeVisible() | |
| 295 | + await props.getByRole('button', { name: /delete/i }).click() | |
| 296 | + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().length)).toBe(0) | |
| 293 | 297 | }) |
| 294 | 298 | |
| 295 | 299 | test('inline text edit on the textEdit event', async ({ page }) => { |
| 296 | − test.fixme(true, 'moteur v2 requis : événement textEdit + setDrawingText') | |
| 297 | 300 | await setup(page) |
| 298 | 301 | await page.goto('/charts?debug=1') |
| 299 | − await expect(page.getByTestId('ch-textedit')).toBeVisible() | |
| 302 | + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) | |
| 303 | + await page.evaluate(() => { const c = window.__hfmdChart; const b = c.getData(); c.setDrawings([{ id: 'e2e-t1', type: 'text', text: 'Old', points: [{ t: b[b.length - 30].t, price: b[b.length - 30].c }] }]); c.emitter.emit('textEdit', { id: 'e2e-t1', text: 'Old', x: 200, y: 120 }) }) | |
| 304 | + const box = page.getByTestId('ch-textedit') | |
| 305 | + await expect(box).toBeVisible() | |
| 306 | + await box.getByLabel('Drawing text').fill('Breakout') | |
| 307 | + await box.getByLabel('Drawing text').press('Enter') | |
| 308 | + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings()[0].text)).toBe('Breakout') | |
| 300 | 309 | }) |
| 301 | 310 | |
| 302 | 311 | 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 | 312 | await setup(page) |
| 305 | 313 | await page.goto('/charts') |
| 306 | 314 | await expect(page.getByTestId('ch-group-patterns')).toBeVisible() |
@@ -314,26 +322,27 @@ test.describe('charts', () => { | ||
| 314 | 322 | const bar = page.getByTestId('ch-replay') |
| 315 | 323 | await expect(bar).toBeVisible() |
| 316 | 324 | await expect(page.getByTestId('ch-legend-replay')).toContainText('Replay') |
| 317 | − const shown = await page.evaluate(() => window.__hfmdChart.getData().length) | |
| 325 | + const shownExpr = () => { const c = window.__hfmdChart; const r = typeof c.getReplay === 'function' ? c.getReplay() : null; return r && r.active ? r.index + 1 : c.getData().length } | |
| 326 | + const shown = await page.evaluate(shownExpr) | |
| 318 | 327 | expect(shown).toBeLessThan(4200) |
| 319 | 328 | await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown}/4200`) |
| 320 | 329 | const sma = await page.evaluate(() => { const v = window.__hfmdChart.getIndicators()[0].values.sma; return v[v.length - 1] }) |
| 321 | 330 | await bar.getByTestId('ch-replay-fwd').click() |
| 322 | 331 | await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown + 1}/4200`) |
| 323 | − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(shown + 1) | |
| 332 | + await expect.poll(() => page.evaluate(shownExpr)).toBe(shown + 1) | |
| 324 | 333 | 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 | 334 | await bar.getByTestId('ch-replay-speed').selectOption('10') |
| 326 | 335 | await bar.getByTestId('ch-replay-play').click() |
| 327 | 336 | 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) | |
| 337 | + await expect.poll(() => page.evaluate(shownExpr), { timeout: 5000 }).toBeGreaterThan(shown + 3) | |
| 329 | 338 | await bar.getByTestId('ch-replay-play').click() |
| 330 | 339 | await expect(bar.getByTestId('ch-replay-play')).toHaveAttribute('aria-pressed', 'false') |
| 331 | 340 | await bar.getByTestId('ch-replay-slider').fill('100') |
| 332 | − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(101) | |
| 341 | + await expect.poll(() => page.evaluate(shownExpr)).toBe(101) | |
| 333 | 342 | await page.screenshot({ path: '/tmp/hfmd-shots/charts-replay.png' }) |
| 334 | 343 | await bar.getByTestId('ch-replay-exit').click() |
| 335 | 344 | await expect(bar).toHaveCount(0) |
| 336 | − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(4200) | |
| 345 | + await expect.poll(() => page.evaluate(shownExpr)).toBe(4200) | |
| 337 | 346 | }) |
| 338 | 347 | |
| 339 | 348 | test('layouts: 2 side by side, per-chart symbol, active chart drives the toolbar, URL layout= / s2=, Alt+1 back', async ({ page }) => { |
@@ -417,8 +426,8 @@ test.describe('charts', () => { | ||
| 417 | 426 | await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().length)).toBe(1) |
| 418 | 427 | await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' }) |
| 419 | 428 | 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 /) | |
| 429 | + await expect.poll(() => page.evaluate(() => { const c = window.__hfmdChart; return typeof c.getPriceLines === 'function' ? c.getPriceLines().map(l => l.title || l.text || 'Alert') : c.getDrawings().slice(1).map(d => d.text) })).toHaveLength(1) | |
| 430 | + expect(await page.evaluate(() => { const c = window.__hfmdChart; const l = typeof c.getPriceLines === 'function' ? c.getPriceLines()[0] : c.getDrawings()[1]; return l.title || l.text })).toMatch(/^Alert /) | |
| 422 | 431 | await expect(page.getByTestId('ch-draw-clear')).toBeEnabled() |
| 423 | 432 | await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' }) |
| 424 | 433 | await expect(page.getByTestId('ch-ctx-reset')).toBeVisible() |
@@ -437,7 +446,7 @@ test.describe('charts', () => { | ||
| 437 | 446 | await setup(page) |
| 438 | 447 | await page.goto('/charts?s=MSFT&tf=1hour') |
| 439 | 448 | await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
| 440 | − await expect(page.getByTestId('ch-legend-describe')).toContainText(/MSFT 1h: \d+ bars from/) | |
| 449 | + await expect(page.getByTestId('ch-legend-describe')).toContainText(/MSFT.*\d+ (1h|1hour) bars from/) | |
| 441 | 450 | const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 }) |
| 442 | 451 | expect(blobSize).toBe(-1) // not exposed without ?debug=1 |
| 443 | 452 | await page.getByTestId('ch-table-btn').click() |
modified
hfmarketdata/web/src/app/Layout.jsx
+4 −2
@@ -1,5 +1,6 @@ | ||
| 1 | 1 | // App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core. |
| 2 | 2 | // Header is 56 px; every nav target is ≥ 36 px on desktop and ≥ 44 px on touch viewports (theme.css --tap). |
| 3 | +import { createPortal } from 'react-dom' | |
| 3 | 4 | import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react' |
| 4 | 5 | import { Link, NavLink, useLocation } from 'react-router-dom' |
| 5 | 6 | import { useAuth } from './auth.jsx' |
@@ -90,7 +91,7 @@ export default function Layout({ children }) { | ||
| 90 | 91 | </div> |
| 91 | 92 | </header> |
| 92 | 93 | |
| 93 | − {drawer && ( | |
| 94 | + {drawer && createPortal( | |
| 94 | 95 | <> |
| 95 | 96 | <div className="drawer-backdrop" onClick={() => setDrawer(false)} /> |
| 96 | 97 | <nav className="drawer" id="mobile-drawer" aria-label="Mobile"> |
@@ -105,7 +106,8 @@ export default function Layout({ children }) { | ||
| 105 | 106 | {user ? <Link to="/dashboard" className="btn">Dashboard</Link> : <><Link to="/signin" className="btn">Sign in</Link><Link to="/signup" className="btn btn-primary">Create free account</Link></>} |
| 106 | 107 | </div> |
| 107 | 108 | </nav> |
| 108 | − </> | |
| 109 | + </>, | |
| 110 | + document.body | |
| 109 | 111 | )} |
| 110 | 112 | |
| 111 | 113 | <div id="main" style={{ display: 'contents' }}>{children}</div> |
modified
hfmarketdata/web/src/app/theme.css
+3 −2
@@ -217,9 +217,10 @@ input[type="checkbox"], input[type="radio"] { min-height: 0; width: 18px; height | ||
| 217 | 217 | .error-state.is-429 svg { color: var(--warn); } |
| 218 | 218 | .error-state .countdown { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: var(--fs-6); font-weight: 600; color: var(--fg); } |
| 219 | 219 | |
| 220 | −/* ---- mobile drawer ------------------------------------------------------------------------- */ | |
| 220 | +/* ---- mobile drawer (portaled to <body>: never inside the blurred topbar; solid fallback colour for old WebKit) ---- */ | |
| 221 | +:root[data-theme="light"] .drawer { background-color: #f6f7f9; background-color: var(--bg-1); } | |
| 221 | 222 | .drawer-backdrop { position: fixed; inset: 0; z-index: var(--z-drawer); background: rgba(0, 0, 0, .62); } |
| 222 | −.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); } | |
| 223 | +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background-color: #10131a; background-color: var(--bg-1); opacity: 1; -webkit-backdrop-filter: none; backdrop-filter: none; transform: translateZ(0); overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); } | |
| 223 | 224 | .drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; min-height: var(--tap-mobile); } |
| 224 | 225 | .drawer a.navlink { display: flex; align-items: center; min-height: var(--tap-mobile); padding: 0 12px; border-radius: var(--r-md); color: var(--fg-1); font-weight: 500; font-size: var(--fs-3); } |
| 225 | 226 | .drawer a.navlink.active, .drawer a.navlink:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } |
modified
hfmarketdata/web/src/charts/CONTRACT.md
+2 −0
@@ -244,3 +244,5 @@ describeVisible(): string // phrase pour | ||
| 244 | 244 | ``` |
| 245 | 245 | Exports supplémentaires de `engine/index.js` : `listIndicators, INDICATOR_CATEGORIES, INDICATOR_SOURCES, DRAWING_TOOLS_V1, DRAWING_TOOLS_V2, |
| 246 | 246 | DRAWING_TOOL_LABELS, DRAWING_POINT_COUNT`. `Emitter.emit` transmet désormais tous les arguments (les écouteurs v1 à un argument ne changent pas). |
| 247 | + | |
| 248 | +- `setCrosshairPosition({t, price} | null)` — programmatic crosshair for multi-chart sync (draw only, no `crosshairMove` emitted; a real pointer move replaces it). | |
modified
hfmarketdata/web/src/charts/engine/core/chart.js
+17 −0
@@ -792,6 +792,23 @@ export class Chart { | ||
| 792 | 792 | |
| 793 | 793 | /* ───────────────────────── pointer / crosshair state ───────────────────────── */ |
| 794 | 794 | |
| 795 | + /** Programmatic crosshair (multi-chart sync): {t, price} places the crosshair on the bar at `t` (nearest) and at | |
| 796 | + * `price` (main pane; pane centre when price is null); null clears it. Draws only — never emits crosshairMove, | |
| 797 | + * so two synced charts cannot feed each other back. A real pointer move replaces it. */ | |
| 798 | + setCrosshairPosition(pos) { | |
| 799 | + if (!pos || pos.t == null || !this.store.length) { | |
| 800 | + if (this.pointer && this.pointer.external) { this.pointer = null; this.invalidate('overlay') } | |
| 801 | + return | |
| 802 | + } | |
| 803 | + const i = Math.max(0, Math.min(this.store.length - 1, Math.round(this.store.indexOfTime(pos.t)))) | |
| 804 | + const x = this.ts.x(i) | |
| 805 | + if (x < 0 || x > this.plotWidth) { if (this.pointer && this.pointer.external) { this.pointer = null; this.invalidate('overlay') } return } | |
| 806 | + const p = this.mainPane | |
| 807 | + const y = pos.price != null && Number.isFinite(pos.price) ? p.scale.y(pos.price) : (p.height || 0) / 2 | |
| 808 | + this.pointer = { x, y, pane: p, region: 'plot', external: true } | |
| 809 | + this.invalidate('overlay') | |
| 810 | + } | |
| 811 | + | |
| 795 | 812 | setPointer(pos) { |
| 796 | 813 | const prev = this.pointer |
| 797 | 814 | this.pointer = pos |
modified
hfmarketdata/web/src/pages/charts/ChartView.jsx
+2 −2
@@ -458,9 +458,9 @@ const ChartView = forwardRef(function ChartView({ cs, onChange, prefs, theme, ac | ||
| 458 | 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 | 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 | 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 }, | |
| 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, 'selectDrawing')) { try { c.selectDrawing([copy.id]) } catch { /* ignore */ } } else if (has(c, 'drawingSelect')) { try { c.drawingSelect(copy.id) } catch { /* ignore */ } } return copy.id }, | |
| 462 | 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 */ } } }, | |
| 463 | + selectDrawing: id => { const c = chartRef.current; if (has(c, 'selectDrawing')) { try { c.selectDrawing(id == null ? [] : [id]) } catch { /* ignore */ } } else if (has(c, 'drawingSelect')) { try { c.drawingSelect(id) } catch { /* ignore */ } } }, | |
| 464 | 464 | /** Sibling synchronisation. */ |
| 465 | 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 | 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 | 467 | |