web: charts — valeurs de légende depuis les typed arrays du moteur, fitContent adapté à la largeur (mobile ~45 bougies), e2e tolère l'id de panneau réel, README section Charts
3 changed files +30 −3
modified
hfmarketdata/web/README.md
+18 −0
@@ -173,6 +173,24 @@ Every reference page's **Try it** button and the MDX `<TryIt>` component produce | ||
| 173 | 173 | present (`json|csv|parquet`); `api_key` is never included. |
| 174 | 174 | - A deep link with `ep` **auto-runs once** when the form validates (one request). `/playground` alone never calls the API. |
| 175 | 175 | |
| 176 | +## Charts (`/charts`) | |
| 177 | + | |
| 178 | +TradingView-class charting built **from scratch** — no lightweight-charts, no third-party engine. | |
| 179 | + | |
| 180 | +- `src/charts/engine/` — canvas 2D engine, zero dependencies (see `src/charts/CONTRACT.md` for the public API and | |
| 181 | + `src/charts/README.md` for the architecture: index-based time scale, layered canvases, panes, 9 series types, | |
| 182 | + drawings with undo/redo, decimation for 200k+ bars). Unit tests: `npm run test:charts` (`node --test`). | |
| 183 | + Visual harness: `dev/charts-harness.html` (`npm run charts:shots` takes the reference screenshots). | |
| 184 | +- `src/charts/indicators/` — pure indicator math (SMA/EMA/WMA/VWAP, Bollinger/Keltner/Donchian, Supertrend, | |
| 185 | + Ichimoku, RSI/MACD/Stoch/ATR/OBV/ADX/CCI/MFI, volume MA, Heikin-Ashi). `null` until the window is full. | |
| 186 | +- `src/charts/data/` — API layer: symbol resolution (all assets, futures roots and contracts), bar loading with | |
| 187 | + backward pagination (`order=desc` + `end`), merged range cache, quota-aware limits, URL/localStorage state. | |
| 188 | +- `src/pages/charts/` — the page: toolbar, symbol combobox, indicators/compare/adjustment menus, drawing bar, | |
| 189 | + HTML legend fed by `crosshairMove` (aria-live), bars table + CSV, bottom sheet on mobile. Deep link: | |
| 190 | + `/charts?s=AAPL&asset=stock&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&vol=1`. | |
| 191 | +- A signed-in browser gets the account tier on data calls (session cookie → `user:<id>` principal), so no key | |
| 192 | + needs to be pasted; keyless visitors see the 429 countdown with a sign-in CTA. | |
| 193 | + | |
| 176 | 194 | ## Prerender (LCP) |
| 177 | 195 | |
| 178 | 196 | `npm run build` runs `vite build` (with `build.manifest`) then `scripts/prerender.mjs`, which writes static shells for |
modified
hfmarketdata/web/e2e/charts.spec.js
+1 −1
@@ -54,7 +54,7 @@ test.describe('charts', () => { | ||
| 54 | 54 | await expect(page.getByTestId('ch-legend-ind').nth(1)).toContainText('RSI 14') |
| 55 | 55 | await expect(page.getByTestId('ch-type-menu')).toContainText('Line') |
| 56 | 56 | await expect(page.getByTestId('ch-scale-menu')).toContainText('Log') |
| 57 | − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane}`))).toEqual(['sma:20:main', 'rsi:14:new']) | |
| 57 | + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane === 'main' ? 'main' : 'new'}`))).toEqual(['sma:20:main', 'rsi:14:new']) | |
| 58 | 58 | await expect(page).toHaveTitle(/MSFT 1h · Charts/) |
| 59 | 59 | }) |
| 60 | 60 | |
modified
hfmarketdata/web/src/pages/charts/ChartsPage.jsx
+11 −2
@@ -85,7 +85,9 @@ export default function ChartsPage() { | ||
| 85 | 85 | const el = containerRef.current |
| 86 | 86 | if (!el) return undefined |
| 87 | 87 | const s = stateRef.current |
| 88 | − const chart = createChart(el, { theme: buildTheme({ colorblind: prefs.colorblind }), timeframe: s.tf, sessionLabel: timezoneLabel(s.asset), watermark: prefs.watermark ? `${s.label} · ${TF_LABEL[s.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches }) | |
| 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 }) | |
| 89 | 91 | chartRef.current = chart |
| 90 | 92 | if (isDebug()) window.__hfmdChart = chart |
| 91 | 93 | const offs = [ |
@@ -106,9 +108,12 @@ export default function ChartsPage() { | ||
| 106 | 108 | ] |
| 107 | 109 | const onTheme = () => chart.setTheme(buildTheme({ colorblind: readPrefs().colorblind })) |
| 108 | 110 | window.addEventListener('hfmd:theme', onTheme) |
| 111 | + const onResize = () => chart.setOptions({ fitBars: fitBarsFor(el.clientWidth || window.innerWidth) }) | |
| 112 | + window.addEventListener('resize', onResize) | |
| 109 | 113 | return () => { |
| 110 | 114 | offs.forEach(off => off()) |
| 111 | 115 | window.removeEventListener('hfmd:theme', onTheme) |
| 116 | + window.removeEventListener('resize', onResize) | |
| 112 | 117 | cancelAnimationFrame(hoverRaf.current) |
| 113 | 118 | abortRef.current?.abort() |
| 114 | 119 | chart.destroy() |
@@ -126,7 +131,11 @@ export default function ChartsPage() { | ||
| 126 | 131 | const vals = {} |
| 127 | 132 | for (const ind of chart.getIndicators()) { |
| 128 | 133 | vals[ind.id] = {} |
| 129 | − for (const [k, arr] of Object.entries(ind.values || {})) vals[ind.id][k] = Array.isArray(arr) ? arr[arr.length - 1] : null | |
| 134 | + for (const [k, arr] of Object.entries(ind.values || {})) { | |
| 135 | + // the engine stores plots as typed arrays (Float64Array): not Array.isArray, NaN = no value yet | |
| 136 | + const v = arr && typeof arr.length === 'number' && arr.length ? arr[arr.length - 1] : null | |
| 137 | + vals[ind.id][k] = v == null || Number.isNaN(v) ? null : v | |
| 138 | + } | |
| 130 | 139 | } |
| 131 | 140 | setIndValues(vals) |
| 132 | 141 | }, []) |
| 133 | 142 | |