SPB Git forge

spb/hfmarketdata

Public

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

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

web: charts — 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

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 56e73c4

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 &amp; 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 &amp; 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 &amp; notes** | Text, callout, price label, flag |
78 79 | **Measure &amp; positions** | Measure (Δ price, Δ %, bars, duration), price range, date range, date &amp; 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