web: charts — catalogue moteur enregistré au chargement du module (listIndicators est un export, pas une méthode du chart : la bibliothèque n'exposait que 18 indicateurs), flyouts en portail fixe (plus tronqués par la barre défilante), grille de cartes non contrainte, vignettes en boîte 16:9, e2e encyclopédie (bibliothèque, tooltips, docs, liens profonds), README et guide Charting
9 changed files +227 −39
modified
hfmarketdata/web/README.md
+30 −6
@@ -189,14 +189,36 @@ TradingView-class charting built **from scratch** — no lightweight-charts, no | ||
| 189 | 189 | generous first windows (10 000 daily / hourly / 30-min, 15 000 5-min, 20 000 1-min bars) and chained backward |
| 190 | 190 | pagination (`order=desc` + `end`, prefetch under 1 000 bars left, comparisons paginated too), merged range cache, |
| 191 | 191 | URL / localStorage state (multi-chart `layout=`, `s2=`…), chart templates (`templates.js`). |
| 192 | +- `src/charts/catalog/` — the **encyclopedia** (single source of truth for every description): `indicators/*.js` | |
| 193 | + (60 sheets: name, aka, one-liner, summary, formula + variables, how to read, defaults explained, best for, pairs | |
| 194 | + with, pitfalls, history, related) and `tools.js` (42 drawing tools: what for, how to draw click by click, reading | |
| 195 | + tips, shortcut, worked example…), validated against the engine registries by `catalog.test.js`. Loaded as lazy | |
| 196 | + chunks (`loadIndicators()` / `loadTools()`) by the library, the tooltips, the About tab and the docs; `interpret.js` | |
| 197 | + turns current values into plain English for the legend tooltip ("RSI 72 → overbought zone"); `ChartFigure.jsx` | |
| 198 | + (theme-aware `<Thumb>`) and `DocsBlocks.jsx` render the generated thumbnails. | |
| 199 | +- `public/charts/thumbs/` — 408 PNG thumbnails **rendered by our own engine** (`npm run charts:thumbs`, see Scripts): | |
| 200 | + each indicator with its defaults and each tool placed on meaningful pivots of one designed daily series, dark and | |
| 201 | + light, 480×270 (cards, tooltips) and 960×540 (docs, OpenGraph) at DPR 2, plus `index.json`. | |
| 192 | 202 | - `src/pages/charts/` — the page. `ChartsPage.jsx` owns the layout (1 / 2h / 2v / 4 `ChartView`s), the toolbar bound |
| 193 | − to the active chart, the dialogs, the keyboard and the URL; `ChartView.jsx` owns one engine instance (data, | |
| 194 | − indicators, comparisons, drawings, replay, sync). Around them: `IndicatorLibrary` (⌘I: search, categories, | |
| 195 | − favourites, active list), `IndicatorSettings` (Inputs / Style / Visibility, live), `DrawingBar` with flyouts | |
| 196 | − (`drawtools.js` groups; tools the loaded engine does not list are hidden) + `DrawingProps` floating bar, | |
| 203 | + to the active chart, the dialogs, the keyboard, the URL and the first-visit strip ("60 indicators · 42 drawing tools | |
| 204 | + · no limits", three thumbnails); `ChartView.jsx` owns one engine instance (data, indicators, comparisons, drawings, | |
| 205 | + replay, sync). Around them: `IndicatorLibrary` (⌘I: encyclopedia cards with thumbnails, search over name / aka / | |
| 206 | + summary, category tabs, sort, detail panel on hover / focus / ⓘ, active list; `IndicatorDetail` is the shared | |
| 207 | + sheet), `IndicatorSettings` (Inputs / Style / Visibility / **About**, live), `DrawingBar` with flyouts | |
| 208 | + (`drawtools.js` groups use the engine's tool ids; hovering an item shows a rich tooltip, the mobile sheet rows carry | |
| 209 | + the thumbnails) + `DrawingProps` floating bar, `Legend` rows with an encyclopedia tooltip (one-liner + values | |
| 210 | + explained), | |
| 197 | 211 | `ReplayBar`, `ContextMenu`, `ShortcutsDialog`, `Legend` (values per plot, hide / ⚙ / ✕, `aria-live` description of |
| 198 | 212 | the visible range), bottom sheets on mobile. Deep link: |
| 199 | − `/charts?s=AAPL&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&layout=2h&s2=MSFT&tf2=1hour`. | |
| 213 | + `/charts?s=AAPL&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&layout=2h&s2=MSFT&tf2=1hour`; | |
| 214 | + `?tool=fib` arms a drawing tool (deep link from the encyclopedia). `indicators.js` registers the engine's | |
| 215 | + `listIndicators()` at module load (it is a module export, not a chart method) so all 60 types are known before the | |
| 216 | + first render. | |
| 217 | +- **Docs encyclopedia**: `npm run charts:docs` (`scripts/charts-docs.mjs`) generates from the catalogue | |
| 218 | + `content/guides/charts-indicators.mdx` + `charts-ind-<id>.mdx` (60) and `charts-tools.mdx` + `charts-tool-<id>.mdx` | |
| 219 | + (42), plus `src/docs/guides.charts.js` (GUIDES entries with `hidden` — sheets are out of the nav / home cards but in | |
| 220 | + search, prerender, sitemap — `keywords` and `image` = OpenGraph image). Routes: `/docs/charts/indicators[/<id>]`, | |
| 221 | + `/docs/charts/tools[/<id>]`. Re-run after editing the catalogue; the output is committed. | |
| 200 | 222 | - **Engine v2 (additive) is used behind `typeof chart.x === 'function'` guards** so the page also runs on the v1 |
| 201 | 223 | engine: `listIndicators()` (catalog merge in `indicators.js`), `updateIndicator(id, { params, plots, levels })`, |
| 202 | 224 | `drawingSelect` / `selectionChange` + `textEdit` events, `getDrawingStyle` / `setDrawingStyle` / `setDrawingText` / |
@@ -223,7 +245,9 @@ and return 404 for unknown paths (the client renders `NotFound`). | ||
| 223 | 245 | | `scripts/make-brand-assets.mjs` | Renders `public/og.png` (1200×630), `apple-touch-icon.png` (180) and `icon-{32,192,512}.png` from inline HTML with Playwright. | |
| 224 | 246 | | `scripts/make-aapl-pe-sample.mjs` | Builds `content/samples/aapl-pe.json` (weekly AAPL split-adjusted close ÷ 10-K diluted EPS) — homepage fundamentals chart. | |
| 225 | 247 | | `scripts/bench-formats.mjs` + `bench-parquet.py` | Real JSON/CSV/Parquet size measurements used in the Data formats guide. | |
| 226 | −| `scripts/prerender.mjs` | See above. | | |
| 248 | +| `scripts/prerender.mjs` | See above (also reads `src/docs/guides.charts.js`; per-route `og:image` from the MDX `meta.image`). | | |
| 249 | +| `scripts/charts-thumbs.mjs` | `npm run charts:thumbs [-- --only=rsi,fib --kind=indicator\|tool --themes=dark,light --sizes=sm,lg]` — Playwright on `dev/charts-thumbs.html` (our engine, designed synthetic series, drawings on real pivots) → `public/charts/thumbs/<indicator\|tool>-<id>-<theme>[-lg].png` + `index.json`. Idempotent, ~1 min for the 408 files. | | |
| 250 | +| `scripts/charts-docs.mjs` | `npm run charts:docs` — generates the encyclopedia MDX pages + `src/docs/guides.charts.js` from `src/charts/catalog`. | | |
| 227 | 251 | |
| 228 | 252 | ## E2E |
| 229 | 253 | |
modified
hfmarketdata/web/content/guides/charts.mdx
+11 −7
@@ -1,4 +1,4 @@ | ||
| 1 | −export const meta = { title: 'Charting', description: 'The /charts page: every symbol of the API from 1-minute to daily, 1 / 2 / 4 chart layouts, indicator library and settings, 45 drawing tools, bar replay, templates, keyboard shortcuts, shareable URLs — free, no account, no limit.' } | |
| 1 | +export const meta = { title: 'Charting', description: 'The /charts page: every symbol of the API from 1-minute to daily, 1 / 2 / 4 chart layouts, indicator library and settings, 60 indicators and 42 drawing tools with their illustrated encyclopedia, bar replay, templates, keyboard shortcuts, shareable URLs — free, no account, no limit.' } | |
| 2 | 2 | |
| 3 | 3 | export const snippets = [ |
| 4 | 4 | { |
@@ -42,7 +42,7 @@ Equities and ETFs default to the split-and-dividend-adjusted series; the **Adjus | ||
| 42 | 42 | |
| 43 | 43 | ## Indicators |
| 44 | 44 | |
| 45 | −The **Indicators** menu adds an indicator in one click; the **Indicator library** (<kbd>⌘</kbd> <kbd>I</kbd>, or the first entry of that menu) is the full catalogue: search by name, id or description, category tabs — **Favorites, Trend, Momentum, Volatility, Volume, Bands, Support/Resistance, Statistics** — a short description of each indicator, a star to keep your favourites (stored in your browser), and the list of active indicators with reorder / hide / settings / remove. | |
| 45 | +The **Indicators** menu adds an indicator in one click; the **Indicator library** (<kbd>⌘</kbd> <kbd>I</kbd>, or the first entry of that menu) is the full catalogue of **60 indicators**: cards with a thumbnail rendered by our own engine, search by name, abbreviation or what it does, category tabs — **Favorites, Trend, Momentum, Volatility, Volume, Bands, Support/Resistance** — sort by category / A–Z / popularity, a star to keep your favourites (stored in your browser), and the list of active indicators with reorder / hide / settings / remove. Hovering (or focusing) a card opens its **encyclopedia sheet** on the right — what it measures, the formula, how to read it, why the defaults, what it pairs with (one click adds the companion), pitfalls, history — and the ⓘ button pins it (on a phone it opens the sheet). The same sheets are published as the [indicator encyclopedia](/docs/charts/indicators), each with an *Open in charts* button. | |
| 46 | 46 | |
| 47 | 47 | Every active indicator has a row in the legend with its values coloured per plot, plus three buttons: **hide** (kept in the URL and the legend, removed from the chart), **⚙ settings** (also on double-click of the row) and **×**. The settings dialog has three tabs and applies every change live: |
| 48 | 48 | |
@@ -51,6 +51,7 @@ Every active indicator has a row in the legend with its values coloured per plot | ||
| 51 | 51 | | **Inputs** | The form is generated from the indicator's inputs — lengths, multipliers, price source (`close`, `hl2`, `ohlc4`…), anchors, with sliders and bounds. | |
| 52 | 52 | | **Style** | Per plot: colour, line width, line style (solid / dashed / dotted), visibility; the levels of oscillators (30 / 70, ±100…). | |
| 53 | 53 | | **Visibility** | The timeframes on which the indicator is shown — an intraday-only VWAP, a daily-only 200 SMA. | |
| 54 | +| **About** | The full encyclopedia sheet of the indicator, with a link to its docs page. | | |
| 54 | 55 | |
| 55 | 56 | **Reset defaults** restores the catalogue values. Colours follow a fixed 8-colour palette so the same indicator keeps the same colour across symbols unless you override it. |
| 56 | 57 | |
@@ -63,7 +64,7 @@ Every active indicator has a row in the legend with its values coloured per plot | ||
| 63 | 64 | | **Price scale** | Linear, logarithmic or percent; auto-scale can be turned off after you drag the price axis. | |
| 64 | 65 | | **Settings** | Volume histogram, grid, colour-blind mode (hollow candles + a blue / orange palette), watermark, drawing toolbar, reduced motion, screen-reader announcements, magnet crosshair, "stay in drawing mode", and the multi-chart sync options. | |
| 65 | 66 | |
| 66 | −The legend always shows the values under the crosshair — or the last bar when the pointer leaves the chart — and the **Table** button opens the same data as an accessible table with a CSV export, so nothing depends on hovering. After every pan or zoom an `aria-live` region describes the visible range (bars, dates, open / close, change, high / low). | |
| 67 | +Hovering an indicator's name in the legend shows its one-liner and the current values explained in plain English ("RSI 72 → overbought zone", "ADX 31 → trending market"). The legend always shows the values under the crosshair — or the last bar when the pointer leaves the chart — and the **Table** button opens the same data as an accessible table with a CSV export, so nothing depends on hovering. After every pan or zoom an `aria-live` region describes the visible range (bars, dates, open / close, change, high / low). | |
| 67 | 68 | |
| 68 | 69 | ## Drawing tools |
| 69 | 70 | |
@@ -71,13 +72,15 @@ The vertical bar on the left is organised in groups with flyouts, like a profess | ||
| 71 | 72 | |
| 72 | 73 | | Group | Tools | |
| 73 | 74 | | --- | --- | |
| 74 | −| **Lines** | Trend line, ray, extended line, horizontal ray, horizontal line, vertical line, cross line, parallel channel (2 and 3 points), regression trend, Andrews pitchfork, Schiff pitchfork | | |
| 75 | −| **Fibonacci & Gann** | Retracement, extension, time zones, fan, arcs, Gann fan, Gann box | | |
| 76 | −| **Shapes** | Rectangle, ellipse, triangle, polyline, brush, arrow, arrow marker | | |
| 75 | +| **Lines** | Trend line, ray, extended line, horizontal ray, horizontal line, vertical line, cross line, parallel channel (3 points), regression trend, Andrews pitchfork, Schiff pitchfork, modified Schiff pitchfork | | |
| 76 | +| **Fibonacci & Gann** | Retracement, trend-based extension, time zones, speed/resistance fan, arcs, Gann fan, Gann box | | |
| 77 | +| **Shapes** | Rectangle, ellipse, triangle, path (polyline), brush, arrow, arrow up / down markers | | |
| 77 | 78 | | **Text & notes** | Text, callout, price label, flag | |
| 78 | 79 | | **Measure & positions** | Measure (Δ price, Δ %, bars, duration), price range, date range, date & price range, vertical range, long position, short position | |
| 79 | 80 | | **Patterns** | Elliott impulse wave (12345), Elliott correction wave (ABC), XABCD, head and shoulders | |
| 80 | 81 | |
| 82 | +Hovering a tool in a flyout shows its illustration (rendered by our engine), what it is for, the click sequence and its shortcut; every tool has a full page in the [drawing tools encyclopedia](/docs/charts/tools), with a *Draw it in charts* button (`/charts?tool=fib`). | |
| 83 | + | |
| 81 | 84 | Below the groups: **magnet** (snap to open / high / low / close), **stay in drawing mode** (the tool stays armed after each drawing), **hide all**, **lock all**, undo / redo and **remove all**. Selecting a drawing opens a floating properties bar — colour, line width, line style, fill opacity, label text, lock, duplicate, delete — and double-clicking a text opens it for inline editing. Drawings are saved per symbol and timeframe in your browser. |
| 82 | 85 | |
| 83 | 86 | The **right-click menu** on the chart offers *reset view*, *horizontal line at this price*, *alert line at this price* (a dashed price line with a label), *copy price*, *screenshot*, *table* and *settings*. |
@@ -126,7 +129,8 @@ https://www.hfmarketdata.io/charts?s=AAPL&tf=1hour&type=hollow&ind=ema:20,ema:50 | ||
| 126 | 129 | | `asset` | `stock` (default), `etf`, `index`, `futures`, `contract`, `crypto`, `fx` | |
| 127 | 130 | | `tf` | `1min`, `5min`, `30min`, `1hour`, `1day` (default) | |
| 128 | 131 | | `type` | `candles` (default), `hollow`, `ohlc`, `line`, `area`, `baseline`, `heikin`, `columns`, `hlc` | |
| 129 | −| `ind` | Comma-separated `type:param1:param2…`, e.g. `sma:20`, `macd:12:26:9`, `bollinger:20:2` | | |
| 132 | +| `ind` | Comma-separated `type:param1:param2…`, e.g. `sma:20`, `macd:12:26:9`, `bollinger:20:2` (any of the [60 ids](/docs/charts/indicators); no params = defaults) | | |
| 133 | +| `tool` | Arms a drawing tool on open, e.g. `tool=fib` (any of the [42 ids](/docs/charts/tools)) | | |
| 130 | 134 | | `cmp` | Comma-separated symbols, `TICKER@asset` for non-stocks (`ES@futures`) | |
| 131 | 135 | | `scale` | `linear` (default), `log`, `percent` | |
| 132 | 136 | | `vol` | `0` hides the volume histogram | |
modified
hfmarketdata/web/e2e/charts.spec.js
+117 −0
@@ -152,6 +152,106 @@ test.describe('charts', () => { | ||
| 152 | 152 | await expect(page.getByTestId('ch-library').getByTestId('ch-lib-cat-favorites')).toContainText('(1)') |
| 153 | 153 | }) |
| 154 | 154 | |
| 155 | + test('encyclopedia: library cards carry engine-rendered thumbnails, the detail panel explains the hovered indicator, pairs-with adds', async ({ page }) => { | |
| 156 | + await setup(page) | |
| 157 | + await page.goto('/charts?debug=1') | |
| 158 | + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) | |
| 159 | + await page.keyboard.press('ControlOrMeta+i') | |
| 160 | + const lib = page.getByTestId('ch-library') | |
| 161 | + await expect(lib).toBeVisible() | |
| 162 | + const card = lib.getByTestId('ch-lib-card-rsi') | |
| 163 | + await lib.getByTestId('ch-lib-cat-momentum').click() | |
| 164 | + await expect(card).toBeVisible() | |
| 165 | + await expect(card.locator('img')).toHaveAttribute('src', '/charts/thumbs/indicator-rsi-dark.png') | |
| 166 | + await expect(card).toContainText(/oversold/) | |
| 167 | + await card.hover() | |
| 168 | + const detail = lib.getByTestId('ch-lib-detail') | |
| 169 | + await expect(detail).toContainText('Relative Strength Index') | |
| 170 | + await expect(detail).toContainText('Formula') | |
| 171 | + await expect(detail).toContainText('Wilder') | |
| 172 | + await expect(detail.getByTestId('ch-detail-docs')).toHaveAttribute('href', '/docs/charts/indicators/rsi') | |
| 173 | + // sort + search over the encyclopedia text | |
| 174 | + await lib.getByTestId('ch-lib-sort').selectOption('az') | |
| 175 | + await lib.getByTestId('ch-lib-search').fill('squeeze') | |
| 176 | + await expect(lib.getByTestId('ch-lib-card-bb-width')).toBeVisible() | |
| 177 | + await lib.getByTestId('ch-lib-search').fill('') | |
| 178 | + // pairs-with chip adds the companion indicator | |
| 179 | + await lib.getByTestId('ch-lib-card-rsi').hover() | |
| 180 | + await detail.getByTestId('ch-detail-pair-macd').click() | |
| 181 | + await expect(lib.getByTestId('ch-lib-active-row')).toHaveCount(1) | |
| 182 | + await expect(lib.getByTestId('ch-lib-active-row')).toContainText('MACD') | |
| 183 | + await page.screenshot({ path: '/tmp/hfmd-shots/charts-library-encyclopedia.png' }) | |
| 184 | + await page.keyboard.press('Escape') | |
| 185 | + // settings → About tab = the same sheet | |
| 186 | + await page.getByTestId('ch-legend-gear').click() | |
| 187 | + await page.getByTestId('ch-ind-tab-about').click() | |
| 188 | + await expect(page.getByTestId('ch-ind-about')).toContainText('Appel') | |
| 189 | + await page.getByTestId('ch-ind-done').click() | |
| 190 | + // legend tooltip: one-liner + values explained | |
| 191 | + await page.getByTestId('ch-legend-ind').first().locator('.ch-legend-btn').hover() | |
| 192 | + const tip = page.getByTestId('ch-legend-tip') | |
| 193 | + await expect(tip).toBeVisible() | |
| 194 | + await expect(tip).toContainText(/histogram (above|below) zero/) | |
| 195 | + }) | |
| 196 | + | |
| 197 | + test('encyclopedia: tool flyout items show a rich tooltip (thumbnail + steps), ?tool= deep link arms the tool, first-visit strip', async ({ page }) => { | |
| 198 | + await setup(page) | |
| 199 | + await page.goto('/charts?debug=1&tool=fib') | |
| 200 | + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) | |
| 201 | + await expect(page.getByTestId('ch-group-fib')).toHaveAttribute('aria-pressed', 'true') | |
| 202 | + await expect.poll(() => page.evaluate(() => window.__hfmdChart.drawings.tool)).toBe('fib') | |
| 203 | + await page.keyboard.press('Escape') | |
| 204 | + // first-visit strip: counts + three thumbnails; clicking one adds the indicator and dismisses the strip for good | |
| 205 | + const intro = page.getByTestId('ch-intro') | |
| 206 | + await expect(intro).toContainText('60 indicators · 42 drawing tools · no limits') | |
| 207 | + await expect(intro.locator('img')).toHaveCount(3) | |
| 208 | + await intro.getByTestId('ch-intro-bollinger').click() | |
| 209 | + await expect(page.getByTestId('ch-legend-ind')).toContainText('BB 20 2') | |
| 210 | + await expect(intro).toHaveCount(0) | |
| 211 | + // flyout tooltip | |
| 212 | + await page.getByTestId('ch-group-more-measure').click() | |
| 213 | + const fly = page.getByTestId('ch-flyout-measure') | |
| 214 | + await fly.locator('[data-tool="long"]').hover() | |
| 215 | + const tip = page.getByTestId('ch-tool-tip') | |
| 216 | + await expect(tip).toBeVisible() | |
| 217 | + await expect(tip).toContainText('Long position') | |
| 218 | + await expect(tip.locator('img')).toHaveAttribute('src', '/charts/thumbs/tool-long-dark.png') | |
| 219 | + await expect(tip.locator('ol li').first()).toBeVisible() | |
| 220 | + await page.screenshot({ path: '/tmp/hfmd-shots/charts-tool-tooltip.png' }) | |
| 221 | + await page.keyboard.press('Escape') | |
| 222 | + await page.reload() | |
| 223 | + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) | |
| 224 | + await expect(page.getByTestId('ch-intro')).toHaveCount(0) | |
| 225 | + }) | |
| 226 | + | |
| 227 | + test('docs encyclopedia: index by category with thumbnails, a sheet renders formula + figure + "Open in charts" deep link', async ({ page }) => { | |
| 228 | + await setup(page) | |
| 229 | + await page.goto('/docs/charts/indicators') | |
| 230 | + await expect(page.getByRole('heading', { level: 1, name: 'Indicator encyclopedia' })).toBeVisible() | |
| 231 | + await expect(page.getByRole('navigation', { name: 'Documentation' })).toContainText('Indicator encyclopedia') | |
| 232 | + expect(await page.getByRole('navigation', { name: 'Documentation' }).getByRole('link', { name: 'Relative Strength Index' }).count()).toBe(0) // sheets are not in the nav | |
| 233 | + const grid = page.getByTestId('ch-docs-grid-indicator').first() | |
| 234 | + await expect(grid.locator('a')).toHaveCount(18) // trend | |
| 235 | + await expect(page.getByTestId('ch-docs-card-rsi').locator('img')).toHaveAttribute('src', /indicator-rsi-(dark|light)\.png/) | |
| 236 | + await page.getByTestId('ch-docs-card-rsi').click() | |
| 237 | + await expect(page).toHaveURL(/\/docs\/charts\/indicators\/rsi$/) | |
| 238 | + await expect(page.getByRole('heading', { level: 1, name: 'Relative Strength Index' })).toBeVisible() | |
| 239 | + await expect(page.getByTestId('ch-figure').locator('img')).toHaveAttribute('src', /indicator-rsi-(dark|light)-lg\.png/) | |
| 240 | + await expect(page.getByRole('heading', { level: 2, name: 'Formula' })).toBeVisible() | |
| 241 | + await expect(page.getByTestId('guide-page')).toContainText('RSI = 100 − 100 / (1 + RS)') | |
| 242 | + await expect(page.getByRole('complementary', { name: 'Code and outline' }).getByRole('navigation', { name: 'On this page' })).toContainText('How to read it') | |
| 243 | + await expect(page).toHaveTitle(/RSI — Relative Strength Index/) | |
| 244 | + await page.screenshot({ path: '/tmp/hfmd-shots/docs-indicator-rsi.png', fullPage: false }) | |
| 245 | + await page.getByTestId('ch-docs-open').click() | |
| 246 | + await expect(page).toHaveURL(/\/charts\?ind=rsi/) | |
| 247 | + await expect(page.getByTestId('ch-legend-ind')).toContainText('RSI 14') | |
| 248 | + // tools index + a sheet | |
| 249 | + await page.goto('/docs/charts/tools/fib') | |
| 250 | + await expect(page.getByRole('heading', { level: 1, name: 'Fibonacci retracement' })).toBeVisible() | |
| 251 | + await expect(page.getByRole('heading', { level: 2, name: 'How to draw it' })).toBeVisible() | |
| 252 | + await expect(page.getByTestId('ch-docs-open')).toHaveAttribute('href', '/charts?tool=fib') | |
| 253 | + }) | |
| 254 | + | |
| 155 | 255 | test('compare: adds a % overlay, chip + legend row, one request, URL cmp=', async ({ page }) => { |
| 156 | 256 | const state = await setup(page) |
| 157 | 257 | await page.goto('/charts') |
@@ -502,5 +602,22 @@ test.describe('charts mobile', () => { | ||
| 502 | 602 | await fly.locator('[data-tool="rect"]').click() |
| 503 | 603 | await expect(bar.getByTestId('ch-group-shapes')).toHaveAttribute('aria-pressed', 'true') |
| 504 | 604 | await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile.png' }) |
| 605 | + // library sheet: cards with thumbnails, ⓘ opens the detail view | |
| 606 | + await page.getByTestId('ch-more').click() | |
| 607 | + await page.getByTestId('ch-sheet-library').click() | |
| 608 | + await expect(page.getByTestId('ch-lib-card-rsi').locator('img')).toHaveAttribute('src', /indicator-rsi/) | |
| 609 | + await page.getByTestId('ch-lib-info-rsi').click() | |
| 610 | + await expect(page.getByTestId('ch-ind-detail')).toContainText('Formula') | |
| 611 | + await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile-library-detail.png' }) | |
| 612 | + await page.getByTestId('ch-lib-back').click() | |
| 613 | + await expect(page.getByTestId('ch-lib-card-rsi')).toBeVisible() | |
| 614 | + await page.getByTestId('ch-library').getByRole('button', { name: 'Close' }).click() | |
| 615 | + // drawing flyout sheet rows carry the thumbnail + one-liner (the drawing bar is still open) | |
| 616 | + await page.getByTestId('ch-drawbar').getByTestId('ch-group-more-fib').click() | |
| 617 | + const fibSheet = page.getByTestId('ch-flyout-fib') | |
| 618 | + await expect(fibSheet.locator('[data-tool="fib"] img')).toHaveAttribute('src', /tool-fib/) | |
| 619 | + await expect(fibSheet.locator('[data-tool="fib"]')).toContainText(/pullbacks|swing/i) | |
| 620 | + await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile-tools-sheet.png' }) | |
| 621 | + await page.keyboard.press('Escape') | |
| 505 | 622 | }) |
| 506 | 623 | }) |
modified
hfmarketdata/web/src/charts/catalog/ChartFigure.jsx
+7 −2
@@ -37,8 +37,13 @@ export function Thumb({ kind, id, size = 'sm', alt = '', className = '', theme, | ||
| 37 | 37 | const t = theme || site |
| 38 | 38 | const [failed, setFailed] = useState(false) |
| 39 | 39 | useEffect(() => { setFailed(false) }, [kind, id, t, size]) |
| 40 | − if (failed) return <span className={`ch-thumb ch-thumb-missing ${className}`} aria-hidden="true" data-testid="ch-thumb-missing" /> | |
| 41 | − return <img className={`ch-thumb ${className}`} src={thumbUrl(kind, id, t, size)} width={size === 'lg' ? 960 : 480} height={size === 'lg' ? 540 : 270} loading={eager ? 'eager' : 'lazy'} decoding="async" alt={alt} onError={() => setFailed(true)} data-testid="ch-thumb" {...rest} /> | |
| 40 | + // A non-replaced 16:9 box carries the size (grid / flex intrinsic sizing would resolve a %-width <img> against zero); | |
| 41 | + // the image fills it absolutely. | |
| 42 | + return ( | |
| 43 | + <span className={`ch-thumb ${failed ? 'ch-thumb-missing' : ''} ${className}`} data-testid={failed ? 'ch-thumb-missing' : 'ch-thumb'} {...rest}> | |
| 44 | + {!failed && <img className="ch-thumb-img" src={thumbUrl(kind, id, t, size)} width={size === 'lg' ? 960 : 480} height={size === 'lg' ? 540 : 270} loading={eager ? 'eager' : 'lazy'} decoding="async" alt={alt} onError={() => setFailed(true)} />} | |
| 45 | + </span> | |
| 46 | + ) | |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | 49 | /** Docs figure: large thumbnail + caption, used by the generated MDX pages. */ |
modified
hfmarketdata/web/src/pages/charts/DrawingBar.jsx
+29 −7
@@ -4,6 +4,7 @@ | ||
| 4 | 4 | // draw, shortcut) read from the lazy encyclopedia; under 640 px the flyouts are bottom sheets whose rows carry the |
| 5 | 5 | // thumbnail and the one-liner. Below the groups: magnet, "stay in drawing mode", hide all, lock all, undo / redo, remove all. |
| 6 | 6 | import React, { useEffect, useRef, useState } from 'react' |
| 7 | +import { createPortal } from 'react-dom' | |
| 7 | 8 | import { availableGroups, toolGroup } from './drawtools.js' |
| 8 | 9 | import BottomSheet from './BottomSheet.jsx' |
| 9 | 10 | import { ChevronDownIcon } from '../../components/Icons.jsx' |
@@ -27,9 +28,28 @@ export function ToolTip({ entry, tool, className = '' }) { | ||
| 27 | 28 | ) |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | −function Flyout({ group, tool, onPick, onClose, horizontal, entries }) { | |
| 31 | +/** | |
| 32 | + * Desktop flyout: rendered in a body portal at a fixed position next to the group button (the drawing bar scrolls | |
| 33 | + * vertically with `overflow: auto`, which would clip an absolutely positioned child — the bug seen in production). | |
| 34 | + */ | |
| 35 | +function Flyout({ group, tool, onPick, onClose, horizontal, entries, anchor, anchorId }) { | |
| 31 | 36 | const ref = useRef(null) |
| 32 | 37 | const [tip, setTip] = useState(null) |
| 38 | + const [pos, setPos] = useState(null) | |
| 39 | + // passive effect: callback refs are re-attached after the layout phase of this portal, so read the anchor here | |
| 40 | + useEffect(() => { | |
| 41 | + const place = () => { | |
| 42 | + const el = anchor?.current?.[anchorId] || document.querySelector(`.ch-toolgroup[data-group="${anchorId}"]`) | |
| 43 | + const r = el?.getBoundingClientRect() | |
| 44 | + if (!r) return | |
| 45 | + const h = ref.current?.offsetHeight || 0 | |
| 46 | + const top = Math.max(8, Math.min(r.top, window.innerHeight - h - 8)) | |
| 47 | + setPos({ left: Math.round(r.right + 6), top: Math.round(top) }) | |
| 48 | + } | |
| 49 | + place() | |
| 50 | + window.addEventListener('resize', place) | |
| 51 | + return () => window.removeEventListener('resize', place) | |
| 52 | + }, [anchor, anchorId]) | |
| 33 | 53 | useEffect(() => { |
| 34 | 54 | const onDoc = e => { if (!ref.current?.contains(e.target)) onClose() } |
| 35 | 55 | const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } } |
@@ -39,8 +59,8 @@ function Flyout({ group, tool, onPick, onClose, horizontal, entries }) { | ||
| 39 | 59 | return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true) } |
| 40 | 60 | }, [onClose]) |
| 41 | 61 | const tipTool = tip ? group.tools.find(t => t.id === tip) : null |
| 42 | − return ( | |
| 43 | − <div ref={ref} className={`ch-flyout ${horizontal ? 'is-below' : ''}`} role="menu" aria-label={group.label} data-testid={`ch-flyout-${group.id}`} onMouseLeave={() => setTip(null)}> | |
| 62 | + return createPortal( | |
| 63 | + <div ref={ref} className={`ch-flyout is-fixed ${horizontal ? 'is-below' : ''}`} style={pos ? { left: pos.left, top: pos.top } : { visibility: 'hidden' }} role="menu" aria-label={group.label} data-testid={`ch-flyout-${group.id}`} onMouseLeave={() => setTip(null)}> | |
| 44 | 64 | <div className="ch-flyout-title">{group.label}</div> |
| 45 | 65 | {group.tools.map(t => { const Icon = TOOL_ICONS[t.id] || CursorIcon; return ( |
| 46 | 66 | <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)} |
@@ -49,7 +69,8 @@ function Flyout({ group, tool, onPick, onClose, horizontal, entries }) { | ||
| 49 | 69 | </button> |
| 50 | 70 | ) })} |
| 51 | 71 | {tipTool && entries?.[tipTool.id] && <div id="ch-tool-tip" className="ch-flyout-tip"><ToolTip entry={entries[tipTool.id]} tool={tipTool} /></div>} |
| 52 | − </div> | |
| 72 | + </div>, | |
| 73 | + document.body, | |
| 53 | 74 | ) |
| 54 | 75 | } |
| 55 | 76 | |
@@ -61,6 +82,7 @@ export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magn | ||
| 61 | 82 | const pick = (g, id) => { onLastTool?.(g.id, id); onTool(tool === id ? null : id); setOpen(null) } |
| 62 | 83 | const lastOf = g => g.tools.find(t => t.id === lastTools[g.id]) || g.tools[0] |
| 63 | 84 | const longPress = useRef(0) |
| 85 | + const anchors = useRef({}) // group id → toolgroup element (stable store: a per-render ref object is attached AFTER the portal's layout effect) | |
| 64 | 86 | |
| 65 | 87 | return ( |
| 66 | 88 | <div className={`ch-drawbar ${horizontal ? 'is-horizontal' : ''}`} role="toolbar" aria-label="Drawing tools" aria-orientation={horizontal ? 'horizontal' : 'vertical'} data-testid="ch-drawbar"> |
@@ -72,7 +94,7 @@ export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magn | ||
| 72 | 94 | const ActiveIcon = active ? (TOOL_ICONS[tool] || Icon) : Icon |
| 73 | 95 | const label = active ? g.tools.find(t => t.id === tool)?.label || last.label : last.label |
| 74 | 96 | return ( |
| 75 | − <div key={g.id} className={`ch-toolgroup ${open === g.id ? 'is-open' : ''}`} data-group={g.id}> | |
| 97 | + <div key={g.id} className={`ch-toolgroup ${open === g.id ? 'is-open' : ''}`} data-group={g.id} ref={el => { anchors.current[g.id] = el }}> | |
| 76 | 98 | <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}`} |
| 77 | 99 | onClick={() => { if (Date.now() - longPress.current < 400) return; if (active) { onTool(null); return } onLastTool?.(g.id, last.id); onTool(last.id) }} |
| 78 | 100 | onContextMenu={e => { e.preventDefault(); setOpen(o => (o === g.id ? null : g.id)) }} |
@@ -80,13 +102,13 @@ export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magn | ||
| 80 | 102 | <ActiveIcon /> |
| 81 | 103 | </button> |
| 82 | 104 | <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> |
| 83 | − {open === g.id && !horizontal && <Flyout group={g} tool={tool} onPick={id => pick(g, id)} onClose={() => setOpen(null)} entries={entries} />} | |
| 105 | + {open === g.id && !horizontal && <Flyout group={g} tool={tool} onPick={id => pick(g, id)} onClose={() => setOpen(null)} entries={entries} anchor={anchors} anchorId={g.id} />} | |
| 84 | 106 | {open === g.id && horizontal && ( |
| 85 | 107 | <BottomSheet open onClose={() => setOpen(null)} title={g.label} testId={`ch-flyout-${g.id}`}> |
| 86 | 108 | <div className="ch-sheet-grid"> |
| 87 | 109 | {g.tools.map(t => { const TIcon = TOOL_ICONS[t.id] || CursorIcon; const e = entries?.[t.id]; return ( |
| 88 | 110 | <button key={t.id} type="button" className="ch-sheet-btn ch-sheet-tool" aria-pressed={tool === t.id} data-tool={t.id} onClick={() => pick(g, t.id)}> |
| 89 | − <Thumb kind="tool" id={t.id} size="sm" className="ch-sheet-thumb" /> | |
| 111 | + <Thumb kind="tool" id={t.id} size="sm" className="ch-sheet-thumb" eager /> | |
| 90 | 112 | <span className="ch-sheet-tool-body"><span className="row" style={{ gap: 8 }}><TIcon />{t.label}</span>{e && <span className="ch-sheet-tool-desc">{e.oneLiner}</span>}</span> |
| 91 | 113 | {t.key && <kbd className="muted">{t.key}</kbd>} |
| 92 | 114 | </button> |
modified
hfmarketdata/web/src/pages/charts/IndicatorLibrary.jsx
+4 −2
@@ -36,7 +36,7 @@ export default function IndicatorLibrary({ open, onClose, active = [], onAdd, on | ||
| 36 | 36 | let base = searchCatalog('', q ? 'All' : cat, favorites) |
| 37 | 37 | if (q) { |
| 38 | 38 | const byId = new Map(base.map(d => [d.id, d])) |
| 39 | − const hits = entries ? searchEntries(base.map(d => ({ ...d, ...(entries[d.id] || {}), id: d.id, name: d.name })), q) : searchCatalog(q, 'All', favorites) | |
| 39 | + const hits = entries ? searchEntries(base.map(d => { const e = entries[d.id] || {}; return { ...d, ...e, id: d.id, name: e.name || d.name, aka: [...(e.aka || []), d.name, d.short].filter(Boolean), summary: `${e.summary || ''} ${d.description || ''}` } }), q) : searchCatalog(q, 'All', favorites) | |
| 40 | 40 | base = hits.map(h => byId.get(h.id)).filter(Boolean) |
| 41 | 41 | return base |
| 42 | 42 | } |
@@ -85,13 +85,14 @@ export default function IndicatorLibrary({ open, onClose, active = [], onAdd, on | ||
| 85 | 85 | </div> |
| 86 | 86 | )} |
| 87 | 87 | <div className="ch-lib-count muted small" aria-live="polite">{list.length} of {all.length} indicators{q ? ` match “${q}”` : ''}</div> |
| 88 | + <div className="ch-lib-scroll"> | |
| 88 | 89 | <ul className="ch-lib-list is-cards" role="list" data-testid="ch-lib-list" onMouseLeave={() => setHover(null)}> |
| 89 | 90 | {list.map(d => ( |
| 90 | 91 | <li key={d.id} className={`ch-card ${detailId === d.id ? 'is-current' : ''}`} data-testid={`ch-lib-card-${d.id}`} onMouseEnter={() => setHover(d.id)}> |
| 91 | 92 | <button type="button" className="ch-lib-add ch-card-btn" data-lib-item onClick={() => add(d.id)} onFocus={() => setHover(d.id)} data-testid={`ch-lib-add-${d.id}`} aria-label={`Add ${d.name}`} aria-describedby={`ch-card-desc-${d.id}`}> |
| 92 | 93 | <Thumb kind="indicator" id={d.id} size="sm" className="ch-card-thumb" /> |
| 93 | 94 | <span className="ch-card-body"> |
| 94 | − <span className="ch-lib-name">{d.name}{counts[d.id] ? <span className="ch-lib-count-badge" aria-label={`${counts[d.id]} active`}>×{counts[d.id]}</span> : null}</span> | |
| 95 | + <span className="ch-lib-name">{entries?.[d.id]?.name || d.name}{counts[d.id] ? <span className="ch-lib-count-badge" aria-label={`${counts[d.id]} active`}>×{counts[d.id]}</span> : null}</span> | |
| 95 | 96 | <span className="ch-lib-desc" id={`ch-card-desc-${d.id}`}>{oneLiner(d)}</span> |
| 96 | 97 | </span> |
| 97 | 98 | </button> |
@@ -104,6 +105,7 @@ export default function IndicatorLibrary({ open, onClose, active = [], onAdd, on | ||
| 104 | 105 | ))} |
| 105 | 106 | {!list.length && <li className="ch-lib-empty muted small">No indicator matches “{q}”.</li>} |
| 106 | 107 | </ul> |
| 108 | + </div> | |
| 107 | 109 | </div> |
| 108 | 110 | <aside className="ch-lib-side" aria-label="Indicator details and active indicators"> |
| 109 | 111 | {!isMobile && ( |
modified
hfmarketdata/web/src/pages/charts/charts.css
+10 −6
@@ -207,6 +207,7 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v | ||
| 207 | 207 | .ch-toolgroup:hover .ch-tool-more, .ch-tool-more:focus-visible, .ch-toolgroup.is-open .ch-tool-more { opacity: 1; } |
| 208 | 208 | .ch-tool-more[aria-expanded="true"] { color: var(--accent); } |
| 209 | 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-fixed { position: fixed; z-index: var(--z-modal); max-height: calc(100vh - 16px); overflow: visible; } /* body portal: never clipped by the scrolling drawing bar */ | |
| 210 | 211 | .ch-flyout.is-below { left: 0; top: calc(100% + 6px); } |
| 211 | 212 | .ch-flyout-title { padding: 6px 10px 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); } |
| 212 | 213 | .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; } |
@@ -326,16 +327,18 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v | ||
| 326 | 327 | |
| 327 | 328 | /* ---- encyclopedia: library cards, detail panel, tooltips, intro strip (thumbnails rendered by our engine) -------- */ |
| 328 | 329 | .ch-dialog.is-xl { width: min(1180px, calc(100vw - 32px)); max-height: min(86vh, 860px); } |
| 329 | −.ch-thumb { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--bg-1); object-fit: cover; } | |
| 330 | −.ch-thumb-missing { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--r-sm); border: 1px dashed var(--line-2); background: var(--bg-1); } | |
| 330 | +.ch-thumb { display: block; position: relative; aspect-ratio: 16 / 9; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--bg-1); overflow: hidden; } | |
| 331 | +.ch-thumb-img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; } | |
| 332 | +.ch-thumb-missing { border-style: dashed; border-color: var(--line-2); } | |
| 331 | 333 | .ch-lib-search { display: flex; gap: 8px; align-items: center; } |
| 332 | 334 | .ch-lib-search .select { width: 120px; flex: none; } |
| 333 | 335 | .ch-lib-count { margin: 6px 0 2px; } |
| 334 | −.ch-lib-list.is-cards { --cols: 2; display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 8px; align-content: start; max-height: none; padding: 2px; } | |
| 336 | +.ch-lib-scroll { flex: 1; min-height: 0; max-height: calc(min(86vh, 860px) - 210px); overflow: auto; padding: 2px; scrollbar-width: thin; } /* the grid itself must not be height-constrained: rows would freeze at their minimum */ | |
| 337 | +.ch-lib-list.is-cards { --cols: 2; display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 8px; align-content: start; flex: none; height: auto; max-height: none; overflow: visible; padding: 0; } | |
| 335 | 338 | .ch-card { position: relative; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); overflow: hidden; } |
| 336 | 339 | .ch-card:hover, .ch-card.is-current { border-color: var(--line-3); background: var(--bg-2); } |
| 337 | 340 | .ch-card.is-current { box-shadow: inset 0 0 0 1px var(--accent-soft); } |
| 338 | −.ch-card-btn { flex-direction: column; align-items: stretch; gap: 8px; padding: 8px 8px 30px; width: 100%; } | |
| 341 | +.ch-card-btn { flex: none; flex-direction: column; align-items: stretch; gap: 8px; padding: 8px 8px 30px; width: 100%; } /* flex:none — .ch-lib-add's flex:1 (basis 0) would collapse the grid row */ | |
| 339 | 342 | .ch-card-thumb { border-radius: 4px; } |
| 340 | 343 | .ch-card-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } |
| 341 | 344 | .ch-card .ch-lib-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; line-height: 1.25; } |
@@ -351,7 +354,6 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v | ||
| 351 | 354 | .ch-lib-side .ch-lib-active { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 10px; flex: none; max-height: 34%; overflow: auto; } |
| 352 | 355 | .ch-lib-detail { flex: 1; min-height: 0; overflow: auto; padding-right: 4px; scrollbar-width: thin; } |
| 353 | 356 | .ch-lib { grid-template-columns: minmax(0, 1fr) 340px; } |
| 354 | −.ch-lib-main .ch-lib-list.is-cards { max-height: calc(min(86vh, 860px) - 210px); overflow: auto; } | |
| 355 | 357 | .ch-lib-mobile-detail { display: grid; gap: 10px; } |
| 356 | 358 | .ch-link-btn { border: 0; background: transparent; color: var(--accent); font: inherit; font-size: inherit; padding: 0; cursor: pointer; text-decoration: underline; } |
| 357 | 359 | |
@@ -418,8 +420,10 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v | ||
| 418 | 420 | @media (max-width: 1100px) { .ch-lib { grid-template-columns: minmax(0, 1fr) 300px; } .ch-lib-list.is-cards { --cols: 1; } } |
| 419 | 421 | @media (max-width: 960px) { .ch-intro-text .muted, .ch-intro-link { display: none; } .ch-intro-thumb { width: 84px; } .ch-intro-thumb span { display: none; } } |
| 420 | 422 | @media (max-width: 760px) { |
| 423 | + .ch-lib { grid-template-columns: 1fr; } /* single column: the desktop rule above is declared later than the older media query */ | |
| 421 | 424 | .ch-lib-side { border-left: 0; padding-left: 0; max-height: none; } |
| 422 | − .ch-lib-main .ch-lib-list.is-cards { --cols: 1; max-height: none; overflow: visible; } | |
| 425 | + .ch-lib-main .ch-lib-list.is-cards { --cols: 1; } | |
| 426 | + .ch-lib-scroll { max-height: none; overflow: visible; } | |
| 423 | 427 | .ch-flyout-tip { display: none; } |
| 424 | 428 | } |
| 425 | 429 | @media (max-width: 640px) { .ch-intro { bottom: 8px; gap: 8px; padding: 6px 8px; } .ch-intro-text { font-size: var(--fs-0); white-space: normal; } } |
modified
hfmarketdata/web/src/pages/charts/indicators.js
+14 −7
@@ -2,9 +2,11 @@ | ||
| 2 | 2 | // |
| 3 | 3 | // Two sources, merged into one registry keyed by id: |
| 4 | 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. | |
| 5 | +// * the engine's own `listIndicators()` (v2, additive — a module export of src/charts/engine/index.js, NOT a method of | |
| 6 | +// the chart instance) — `{ id, name, category, pane, defaults, inputs, plots, levels… }` — registered at module load | |
| 7 | +// with `setEngineCatalog()` so the 60 types are known before the first render (URL parsing, library, intro strip). | |
| 8 | +// Engine entries win on inputs/plots (the engine knows its own params), the local entry contributes the | |
| 9 | +// description / category when the engine has none. | |
| 8 | 10 | // |
| 9 | 11 | // Every definition is normalised to the v2 shape: |
| 10 | 12 | // { id, name, category, description, pane: 'main' | 'new', |
@@ -13,6 +15,8 @@ | ||
| 13 | 15 | // plus the legacy fields the page has always used (`type`, `label`, `params` [[key, default, min, max]], `outputs`). |
| 14 | 16 | // URL form: `type:p1:p2…` — the NUMERIC inputs, positional, in catalog order (`sma:20`, `macd:12:26:9`). |
| 15 | 17 | |
| 18 | +import { listIndicators as engineListIndicators } from '../../charts/engine/index.js' | |
| 19 | + | |
| 16 | 20 | export const CATEGORIES = ['Favorites', 'Trend', 'Momentum', 'Volatility', 'Volume', 'Bands', 'Support/Resistance', 'Statistics', 'Other'] |
| 17 | 21 | export const SOURCES = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4'] |
| 18 | 22 | |
@@ -49,13 +53,14 @@ const isNumeric = inp => inp.type === 'int' || inp.type === 'float' || (inp.type | ||
| 49 | 53 | /** Normalise any definition (local or engine) to the shared shape + legacy fields. */ |
| 50 | 54 | function normalize(def, local) { |
| 51 | 55 | 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) | |
| 56 | + // engine entries carry the defaults in `defaults` and the plot kind in `kind` / width in `width` | |
| 57 | + const inputs = (def.inputs || []).map(i => ({ ...i, default: i.default ?? def.defaults?.[i.name] })).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') })) | |
| 58 | + const plots = (def.plots || []).filter(p => p.key).map(p => ({ ...p, type: p.type || p.kind || 'line', lineWidth: p.lineWidth ?? p.width ?? 1, style: p.style || (p.dash ? 'dashed' : 'solid') })) | |
| 54 | 59 | const category = CATEGORIES.includes(def.category) ? def.category : (local?.category || guessCategory(def.category) || 'Other') |
| 55 | 60 | const pane = def.pane || local?.pane || (category === 'Trend' || category === 'Bands' || category === 'Support/Resistance' ? 'main' : 'new') |
| 56 | 61 | return { |
| 57 | 62 | ...def, id, type: id, inputs, plots, category, pane, |
| 58 | − name: def.name || local?.name || id, label: def.name || local?.name || id, | |
| 63 | + name: local?.name || def.name || id, label: local?.name || def.name || id, // local names are the descriptive ones ("Exponential MA (EMA)") | |
| 59 | 64 | short: def.short || local?.short || SHORT[id] || (def.name || id).replace(/\s*\(.*\)$/, '').toUpperCase().slice(0, 10), |
| 60 | 65 | description: def.description || local?.description || '', |
| 61 | 66 | levels: def.levels || local?.levels, |
@@ -72,7 +77,7 @@ function guessCategory(c) { | ||
| 72 | 77 | if (k.includes('volat')) return 'Volatility' |
| 73 | 78 | if (k.includes('volume')) return 'Volume' |
| 74 | 79 | if (k.includes('band') || k.includes('channel')) return 'Bands' |
| 75 | − if (k.includes('support') || k.includes('pivot') || k.includes('resist')) return 'Support/Resistance' | |
| 80 | + if (k.includes('support') || k.includes('pivot') || k.includes('resist') || k.includes('level')) return 'Support/Resistance' | |
| 76 | 81 | if (k.includes('stat') || k.includes('regress') || k.includes('correl')) return 'Statistics' |
| 77 | 82 | return null |
| 78 | 83 | } |
@@ -91,6 +96,8 @@ export function setEngineCatalog(list) { | ||
| 91 | 96 | engineIds = new Set(next.keys()) |
| 92 | 97 | } |
| 93 | 98 | export const hasEngineCatalog = () => engineIds != null |
| 99 | +// v2 engine: register its catalogue right away (the v1 engine has no listIndicators → the local 18 stay). | |
| 100 | +try { if (typeof engineListIndicators === 'function') setEngineCatalog(engineListIndicators()) } catch { /* v1 engine */ } | |
| 94 | 101 | |
| 95 | 102 | /** All definitions, catalog order (engine order when registered). */ |
| 96 | 103 | export function catalog() { return Array.from(registry.values()) } |
modified
hfmarketdata/web/src/pages/docs/docs.css
+5 −2
@@ -156,7 +156,9 @@ | ||
| 156 | 156 | .ch-docs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin: 14px 0 26px; } |
| 157 | 157 | .ch-docs-card { display: flex; flex-direction: column; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); color: var(--fg); text-decoration: none; } |
| 158 | 158 | .ch-docs-card:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); } |
| 159 | −.ch-docs-card img, .ch-docs-card .ch-thumb-missing { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; border-radius: var(--r-sm); border: 1px solid var(--line); } | |
| 159 | +.ch-thumb { display: block; position: relative; aspect-ratio: 16 / 9; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--bg-1); overflow: hidden; } | |
| 160 | +.ch-thumb-img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; } | |
| 161 | +.ch-thumb-missing { border-style: dashed; border-color: var(--line-2); } | |
| 160 | 162 | .ch-docs-card-body { display: grid; gap: 2px; padding: 0 4px 4px; font-size: var(--fs-1); } |
| 161 | 163 | .ch-docs-card-body strong { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; } |
| 162 | 164 | .ch-docs-card-body small { color: var(--fg-3); font-weight: 400; } |
@@ -166,4 +168,5 @@ | ||
| 166 | 168 | .ch-docs-tags { display: flex; flex-wrap: wrap; gap: 4px; } |
| 167 | 169 | .ch-docs-tags .ch-tag { padding: 0 8px; border-radius: 4px; background: var(--bg-2); color: var(--fg-2); font-size: 11px; line-height: 20px; } |
| 168 | 170 | .ch-docs-tags .ch-tag kbd { font-size: 10px; } |
| 169 | −.prose .ch-figure img { width: 100%; height: auto; } | |
| 171 | +.prose .ch-figure .ch-thumb { margin: 0; } | |
| 172 | +.prose .ch-figure img { border: 0; border-radius: 0; } | |
| 170 | 173 | |