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 — bibliothèque encyclopédique (cartes avec vignettes, panneau de détail, recherche name/aka/summary, tri, clavier, mode détail mobile), onglet About des réglages, tooltips riches des flyouts d'outils et des lignes de la légende (valeurs expliquées), bandeau première visite, lien profond ?tool=, 42 outils exposés avec les ids du moteur

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

10 changed files +393 −77

modified hfmarketdata/web/src/pages/charts/ChartsPage.jsx +27 −2
@@ -11,8 +11,9 @@ import { TF_LABEL } from '../../charts/data/bars.js'
11 11 import { LAYOUTS, initialState, parseSearch, readFavorites, readLastTools, readPrefs, toSearch, writeFavorites, writeLast, writeLastTools, writePrefs } from '../../charts/data/state.js'
12 12 import { pushRecent } from '../../charts/data/symbols.js'
13 13 import { defaultTemplate, deleteTemplate, parseTemplateJSON, readTemplates, saveTemplate, setDefaultTemplate, templateFrom, templatesJSON, writeTemplates } from '../../charts/data/templates.js'
14 −import { defaultParams, indicatorDef, sanitizeParams } from './indicators.js'
15 −import { toolGroup } from './drawtools.js'
14 +import { catalog, defaultParams, indicatorDef, sanitizeParams } from './indicators.js'
15 +import { ALL_TOOLS, toolGroup, toolSupported } from './drawtools.js'
16 +import { Thumb } from '../../charts/catalog/ChartFigure.jsx'
16 17 import { buildTheme } from './theme.js'
17 18 import Toolbar from './Toolbar.jsx'
18 19 import DrawingBar, { TOOL_KEYS } from './DrawingBar.jsx'
@@ -37,6 +38,27 @@ const isDebug = () => import.meta.env.DEV || new URLSearchParams(window.location
37 38 const isTyping = e => { const t = e.target?.tagName; return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || e.target?.isContentEditable }
38 39 const ADJ_ASSETS = new Set(['stock', 'etf', 'futures'])
39 40 const LAYOUT_KEYS = ['1', '2h', '2v', '4']
41 +const INTRO_ITEMS = [{ kind: 'indicator', id: 'bollinger', label: 'Bollinger Bands' }, { kind: 'indicator', id: 'rsi', label: 'RSI' }, { kind: 'tool', id: 'fib', label: 'Fibonacci retracement' }]
42 +
43 +/** First-visit strip under the chart: "60 indicators · 42 drawing tools · no limits" with three thumbnails (our engine). */
44 +function IntroBanner({ onAdd, onTool, onDismiss }) {
45 + const nInd = catalog().length
46 + const nTools = ALL_TOOLS.filter(t => toolSupported(t.id)).length
47 + return (
48 + <aside className="ch-intro" data-testid="ch-intro" aria-label="What you can do here">
49 + <div className="ch-intro-text"><strong>{nInd} indicators · {nTools} drawing tools · no limits</strong><span className="muted">Open the library (⌘I) or try one:</span></div>
50 + <div className="ch-intro-thumbs">
51 + {INTRO_ITEMS.map(it => (
52 + <button key={it.id} type="button" className="ch-intro-thumb" onClick={() => (it.kind === 'tool' ? onTool(it.id) : onAdd(it.id))} aria-label={`${it.kind === 'tool' ? 'Draw with' : 'Add'} ${it.label}`} data-testid={`ch-intro-${it.id}`}>
53 + <Thumb kind={it.kind} id={it.id} size="sm" /><span>{it.label}</span>
54 + </button>
55 + ))}
56 + </div>
57 + <a className="ch-intro-link" href="/docs/charts/indicators" target="_blank" rel="noopener">Encyclopedia ↗</a>
58 + <button type="button" className="icon-btn ch-intro-close" aria-label="Dismiss" onClick={onDismiss} data-testid="ch-intro-close">×</button>
59 + </aside>
60 + )
61 +}
40 62
41 63 function nextColorIndex(cs) {
42 64 const used = new Set([...cs.indicators.map(i => i.colorIndex), ...cs.compares.map(c => c.colorIndex)].filter(x => x != null))
@@ -170,6 +192,8 @@ export default function ChartsPage() {
170 192 }, [location.search]) // eslint-disable-line react-hooks/exhaustive-deps
171 193
172 194 useEffect(() => { writeLast(charts[0]) }, [charts[0].asset, charts[0].ticker, charts[0].tf, charts[0].type]) // eslint-disable-line react-hooks/exhaustive-deps
195 + // deep link from the encyclopedia: /charts?tool=fib arms the drawing tool once
196 + useEffect(() => { const t = new URLSearchParams(window.location.search).get('tool'); if (t && toolSupported(t)) setToolAction(t) }, []) // eslint-disable-line react-hooks/exhaustive-deps
173 197 useEffect(() => { pushRecent({ asset: cs.asset, ticker: cs.ticker, label: cs.label }) }, [cs.asset, cs.ticker, cs.label])
174 198 useEffect(() => { writePrefs(prefs) }, [prefs])
175 199 useEffect(() => { writeFavorites(favorites) }, [favorites])
@@ -386,6 +410,7 @@ export default function ChartsPage() {
386 410 replay={replay.on && replay.chartIndex === i ? replay : null} hiddenAll={hiddenAll} lockedAll={lockedAll} drawLock={!!prefs.drawLock} onFirstCatalog={onFirstCatalog} debug={debug} reloadKey={reloadKeys[i] || 0} />
387 411 ))}
388 412 </div>
413 + {!prefs.introSeen && cs.indicators.length === 0 && status.phase !== 'error' && <IntroBanner onAdd={id => { a.addIndicator(id); a.setPref('introSeen', true) }} onTool={id => { setToolAction(id); a.setPref('introSeen', true) }} onDismiss={() => a.setPref('introSeen', true)} />}
389 414 {replay.on && !isMobile && <ReplayBar replay={replay} total={replayTotal} bar={replayBar} tf={charts[replay.chartIndex]?.tf} tz={charts[replay.chartIndex]?.asset === 'contract' ? 'UTC' : 'ET'} onToggle={replayApi.toggle} onStep={replayApi.step} onSpeed={replayApi.speed} onJumpMode={replayApi.jumpMode} onSeek={replayApi.seek} onExit={replayApi.exit} />}
390 415 </div>
391 416 {table && <BarsTable bars={chartData} range={activeRange} state={cs} decimals={activeView?.decimals() ?? 2} tz={cs.asset === 'contract' ? 'UTC' : 'ET'} onClose={() => setTable(false)} />}
modified hfmarketdata/web/src/pages/charts/DrawingBar.jsx +35 −7
@@ -1,17 +1,35 @@
1 1 // Drawing toolbar with flyouts (like a professional terminal): one button per group showing the group's last used
2 2 // tool — click activates it, the chevron (or a right-click / long press) opens the flyout with every tool of the
3 −// group. Below: magnet, "stay in drawing mode" lock, hide all, lock all, undo / redo, remove all. Under 640 px the
4 −// bar is horizontal and the flyouts open as bottom sheets (rendered by the parent through `onOpenGroup`).
3 +// group. Hovering / focusing a flyout item shows a rich tooltip (thumbnail rendered by our engine, one-liner, how to
4 +// draw, shortcut) read from the lazy encyclopedia; under 640 px the flyouts are bottom sheets whose rows carry the
5 +// thumbnail and the one-liner. Below the groups: magnet, "stay in drawing mode", hide all, lock all, undo / redo, remove all.
5 6 import React, { useEffect, useRef, useState } from 'react'
6 7 import { availableGroups, toolGroup } from './drawtools.js'
7 8 import BottomSheet from './BottomSheet.jsx'
8 9 import { ChevronDownIcon } from '../../components/Icons.jsx'
10 +import { Thumb, useCatalog } from '../../charts/catalog/ChartFigure.jsx'
11 +import { docsHref } from '../../charts/catalog/index.js'
9 12 import { CursorIcon, EyeIcon, EyeOffIcon, LockIcon, MagnetIcon, RedoIcon, TOOL_ICONS, TrashIcon, UndoIcon, UnlockIcon, TargetIcon } from './icons.jsx'
10 13
11 14 export { TOOL_KEYS } from './drawtools.js'
12 15
13 −function Flyout({ group, tool, onPick, onClose, horizontal }) {
16 +/** Rich tooltip of one tool (desktop flyouts): image + one-liner + how to draw + shortcut. */
17 +export function ToolTip({ entry, tool, className = '' }) {
18 + if (!entry) return null
19 + return (
20 + <div className={`ch-tooltip ${className}`} role="tooltip" data-testid="ch-tool-tip" data-tool={tool.id}>
21 + <Thumb kind="tool" id={tool.id} size="sm" alt="" className="ch-tooltip-thumb" eager />
22 + <div className="ch-tooltip-head"><strong>{entry.name}</strong>{tool.key && <kbd>{tool.key}</kbd>}</div>
23 + <p className="ch-tooltip-lead">{entry.oneLiner}</p>
24 + <ol className="ch-tooltip-steps">{entry.howToDraw.slice(0, 4).map((s, i) => <li key={i}>{s}</li>)}</ol>
25 + <a className="ch-tooltip-link" href={docsHref('tool', tool.id)} target="_blank" rel="noopener">Full guide ↗</a>
26 + </div>
27 + )
28 +}
29 +
30 +function Flyout({ group, tool, onPick, onClose, horizontal, entries }) {
14 31 const ref = useRef(null)
32 + const [tip, setTip] = useState(null)
15 33 useEffect(() => {
16 34 const onDoc = e => { if (!ref.current?.contains(e.target)) onClose() }
17 35 const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
@@ -20,14 +38,17 @@ function Flyout({ group, tool, onPick, onClose, horizontal }) {
20 38 setTimeout(() => ref.current?.querySelector('[aria-pressed="true"], button')?.focus(), 0)
21 39 return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey, true) }
22 40 }, [onClose])
41 + const tipTool = tip ? group.tools.find(t => t.id === tip) : null
23 42 return (
24 − <div ref={ref} className={`ch-flyout ${horizontal ? 'is-below' : ''}`} role="menu" aria-label={group.label} data-testid={`ch-flyout-${group.id}`}>
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)}>
25 44 <div className="ch-flyout-title">{group.label}</div>
26 45 {group.tools.map(t => { const Icon = TOOL_ICONS[t.id] || CursorIcon; return (
27 − <button key={t.id} type="button" role="menuitemradio" aria-checked={tool === t.id} aria-pressed={tool === t.id} className="ch-flyout-item" data-tool={t.id} onClick={() => onPick(t.id)}>
46 + <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)}
47 + onMouseEnter={() => setTip(t.id)} onFocus={() => setTip(t.id)} aria-describedby={tip === t.id && entries?.[t.id] ? 'ch-tool-tip' : undefined}>
28 48 <Icon /><span>{t.label}</span>{t.key && <kbd>{t.key}</kbd>}
29 49 </button>
30 50 ) })}
51 + {tipTool && entries?.[tipTool.id] && <div id="ch-tool-tip" className="ch-flyout-tip"><ToolTip entry={entries[tipTool.id]} tool={tipTool} /></div>}
31 52 </div>
32 53 )
33 54 }
@@ -35,6 +56,7 @@ function Flyout({ group, tool, onPick, onClose, horizontal }) {
35 56 export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magnet, onMagnet, lock, onLock, hiddenAll, onHideAll, lockedAll, onLockAll, hasDrawings, lastTools = {}, onLastTool, horizontal = false }) {
36 57 const groups = availableGroups()
37 58 const [open, setOpen] = useState(null)
59 + const entries = useCatalog(open ? 'tool' : null)
38 60 const current = toolGroup(tool)
39 61 const pick = (g, id) => { onLastTool?.(g.id, id); onTool(tool === id ? null : id); setOpen(null) }
40 62 const lastOf = g => g.tools.find(t => t.id === lastTools[g.id]) || g.tools[0]
@@ -58,11 +80,17 @@ export default function DrawingBar({ tool, onTool, onUndo, onRedo, onClear, magn
58 80 <ActiveIcon />
59 81 </button>
60 82 <button type="button" className="ch-tool-more" aria-label={`All ${g.label.toLowerCase()} tools`} aria-haspopup="menu" aria-expanded={open === g.id} onClick={() => setOpen(o => (o === g.id ? null : g.id))} data-testid={`ch-group-more-${g.id}`}><ChevronDownIcon /></button>
61 − {open === g.id && !horizontal && <Flyout group={g} tool={tool} onPick={id => pick(g, id)} onClose={() => setOpen(null)} />}
83 + {open === g.id && !horizontal && <Flyout group={g} tool={tool} onPick={id => pick(g, id)} onClose={() => setOpen(null)} entries={entries} />}
62 84 {open === g.id && horizontal && (
63 85 <BottomSheet open onClose={() => setOpen(null)} title={g.label} testId={`ch-flyout-${g.id}`}>
64 86 <div className="ch-sheet-grid">
65 − {g.tools.map(t => { const TIcon = TOOL_ICONS[t.id] || CursorIcon; return <button key={t.id} type="button" className="ch-sheet-btn" aria-pressed={tool === t.id} data-tool={t.id} onClick={() => pick(g, t.id)}><span className="row" style={{ gap: 8 }}><TIcon />{t.label}</span>{t.key && <kbd className="muted">{t.key}</kbd>}</button> })}
87 + {g.tools.map(t => { const TIcon = TOOL_ICONS[t.id] || CursorIcon; const e = entries?.[t.id]; return (
88 + <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" />
90 + <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 + {t.key && <kbd className="muted">{t.key}</kbd>}
92 + </button>
93 + ) })}
66 94 </div>
67 95 </BottomSheet>
68 96 )}
modified hfmarketdata/web/src/pages/charts/DrawingProps.jsx +2 −2
@@ -5,8 +5,8 @@ import { Select } from '../../components/Field.jsx'
5 5 import { DRAW_COLORS, LINE_STYLES, LINE_WIDTHS, toolDef } from './drawtools.js'
6 6 import { CopyIcon, LockIcon, TrashIcon, UnlockIcon } from './icons.jsx'
7 7
8 −const FILLABLE = new Set(['rect', 'ellipse', 'triangle', 'channel', 'channel-parallel', 'gann-box', 'long-position', 'short-position', 'date-price-range', 'price-range', 'date-range', 'vertical-range', 'callout', 'price-label', 'fib', 'fib-extension'])
9 −const TEXTUAL = new Set(['text', 'callout', 'price-label', 'flag', 'arrow-marker', 'hline', 'hray', 'trendline', 'ray', 'extended', 'vline'])
8 +const FILLABLE = new Set(['rect', 'ellipse', 'triangle', 'channel', 'gann-box', 'long', 'short', 'date-price-range', 'price-range', 'date-range', 'vrange', 'callout', 'price-label', 'fib', 'fib-extension'])
9 +const TEXTUAL = new Set(['text', 'callout', 'price-label', 'flag', 'arrow-up', 'arrow-down', 'hline', 'hray', 'trendline', 'ray', 'extended', 'vline'])
10 10
11 11 export default function DrawingProps({ selection, onStyle, onText, onLock, onDuplicate, onDelete, sheet = false }) {
12 12 if (!selection) return null
added hfmarketdata/web/src/pages/charts/IndicatorDetail.jsx +72 −0
@@ -0,0 +1,72 @@
1 +// Encyclopedia sheet of one indicator — the detail panel of the library and the "About" tab of the settings dialog.
2 +// Reads the lazy catalogue entry (src/charts/catalog) + the page definition (inputs) and renders: thumbnail, summary,
3 +// formula, how to read, defaults explained, best for, pairs with (clickable → add), pitfalls, history, docs link.
4 +import React from 'react'
5 +import { INDICATOR_CATEGORY_LABELS, docsHref } from '../../charts/catalog/index.js'
6 +import { Thumb } from '../../charts/catalog/ChartFigure.jsx'
7 +import { indicatorDef } from './indicators.js'
8 +
9 +export default function IndicatorDetail({ entry, def, onAdd, counts = {}, compact = false, testId = 'ch-ind-detail' }) {
10 + if (!entry) return null
11 + const d = def || indicatorDef(entry.id)
12 + return (
13 + <div className={`ch-detail ${compact ? 'is-compact' : ''}`} data-testid={testId} data-id={entry.id}>
14 + <Thumb kind="indicator" id={entry.id} size="sm" alt={`${entry.name} on a daily chart`} className="ch-detail-thumb" eager />
15 + <div className="ch-detail-head">
16 + <h3 className="ch-detail-title">{entry.name}</h3>
17 + <div className="ch-detail-tags">
18 + {entry.aka?.filter(a => a !== entry.name).slice(0, 3).map(a => <span key={a} className="ch-tag mono">{a}</span>)}
19 + <span className="ch-tag">{INDICATOR_CATEGORY_LABELS[entry.category] || entry.category}</span>
20 + <span className="ch-tag">{entry.pane === 'main' ? 'overlay' : 'own pane'}</span>
21 + {counts[entry.id] ? <span className="ch-tag is-on">×{counts[entry.id]} active</span> : null}
22 + </div>
23 + </div>
24 + <p className="ch-detail-lead">{entry.oneLiner}</p>
25 + <p className="ch-detail-text">{entry.summary}</p>
26 + {onAdd && <div className="ch-detail-actions"><button type="button" className="btn btn-sm btn-primary" onClick={() => onAdd(entry.id)} data-testid="ch-detail-add">Add to chart</button><a className="btn btn-sm btn-ghost" href={docsHref('indicator', entry.id)} target="_blank" rel="noopener" data-testid="ch-detail-docs">Read the full guide ↗</a></div>}
27 +
28 + <h4>Formula</h4>
29 + <pre className="ch-detail-formula mono">{entry.formula.text}</pre>
30 + {entry.formula.vars?.length > 0 && <dl className="ch-detail-vars">{entry.formula.vars.map(([k, m]) => <React.Fragment key={k}><dt className="mono">{k}</dt><dd>{m}</dd></React.Fragment>)}</dl>}
31 +
32 + <h4>How to read it</h4>
33 + <ul className="ch-detail-list">{entry.howToRead.map((s, i) => <li key={i}>{s}</li>)}</ul>
34 +
35 + {entry.defaults?.length > 0 && (
36 + <>
37 + <h4>Default settings</h4>
38 + <dl className="ch-detail-defaults">
39 + {entry.defaults.map(([name, value, why]) => (
40 + <React.Fragment key={name}><dt><span className="mono">{name}</span> <b className="mono">{String(value)}</b></dt><dd>{why}</dd></React.Fragment>
41 + ))}
42 + </dl>
43 + </>
44 + )}
45 + {!entry.defaults?.length && d && !d.inputs?.length && <p className="ch-detail-text muted">No inputs — nothing to tune.</p>}
46 +
47 + <h4>Best for</h4>
48 + <dl className="ch-detail-vars">
49 + <dt>Timeframes</dt><dd>{entry.bestFor.timeframes}</dd>
50 + <dt>Markets</dt><dd>{entry.bestFor.markets}</dd>
51 + {entry.bestFor.note && <><dt>Note</dt><dd>{entry.bestFor.note}</dd></>}
52 + </dl>
53 +
54 + {entry.pairsWith?.length > 0 && (
55 + <>
56 + <h4>Pairs well with</h4>
57 + <div className="ch-detail-chips">
58 + {entry.pairsWith.map(id => { const pd = indicatorDef(id); return pd ? <button key={id} type="button" className="ch-chip" onClick={() => onAdd?.(id)} disabled={!onAdd} title={onAdd ? `Add ${pd.name}` : pd.name} data-testid={`ch-detail-pair-${id}`}>{pd.name}{onAdd && <small>+</small>}</button> : null })}
59 + </div>
60 + </>
61 + )}
62 +
63 + <h4>Pitfalls</h4>
64 + <ul className="ch-detail-list">{entry.pitfalls.map((s, i) => <li key={i}>{s}</li>)}</ul>
65 +
66 + <h4>History</h4>
67 + <p className="ch-detail-text">{entry.history}</p>
68 + {entry.related?.length > 0 && <p className="ch-detail-text small">{entry.related.map((r, i) => <React.Fragment key={r.href}>{i > 0 && ' · '}<a href={r.href} target="_blank" rel="noopener">{r.label}</a></React.Fragment>)}</p>}
69 + {!onAdd && <p className="ch-detail-text small"><a href={docsHref('indicator', entry.id)} target="_blank" rel="noopener" data-testid="ch-detail-docs">Read the full guide ↗</a></p>}
70 + </div>
71 + )
72 +}
modified hfmarketdata/web/src/pages/charts/IndicatorLibrary.jsx +109 −50
@@ -1,79 +1,138 @@
1 −// Indicator library (⌘I): searchable, category tabs (Favorites first), one-click add, favourites in localStorage,
2 −// short description per entry, and the list of active indicators with reorder / hide / settings / remove.
1 +// Indicator library (⌘I): encyclopedia cards (thumbnail rendered by our engine, name, one-liner, category, ×n
2 +// active, favourite) with search over name / aka / summary, category tabs, sort (A–Z / category / popular), a detail
3 +// panel on the right (the hovered or focused card; pinned with ⓘ — on phones ⓘ opens the sheet in detail mode) and the
4 +// list of active indicators with reorder / hide / settings / remove. The encyclopedia chunk loads when the dialog opens.
3 5 import React, { useEffect, useMemo, useRef, useState } from 'react'
4 6 import Dialog from './Dialog.jsx'
5 −import { Input } from '../../components/Field.jsx'
7 +import { Input, Select } from '../../components/Field.jsx'
8 +import useMediaQuery from '../../components/useMediaQuery.js'
6 9 import { CATEGORIES, catalog, indicatorShortLabel, searchCatalog } from './indicators.js'
10 +import { searchEntries, sortEntries } from '../../charts/catalog/index.js'
11 +import { Thumb, useCatalog } from '../../charts/catalog/ChartFigure.jsx'
12 +import IndicatorDetail from './IndicatorDetail.jsx'
7 13 import { CloseIcon } from '../../components/Icons.jsx'
8 14 import { DownIcon, EyeIcon, EyeOffIcon, GearIcon, StarIcon, UpIcon } from './icons.jsx'
15 +import { InfoIcon } from '../../components/Icons.jsx'
9 16 import { seriesColor } from './theme.js'
10 17
18 +const SORTS = [['category', 'Category'], ['az', 'A–Z'], ['popular', 'Popular']]
19 +
11 20 export default function IndicatorLibrary({ open, onClose, active = [], onAdd, onRemove, onToggleHidden, onMove, onOpenSettings, favorites = [], onToggleFavorite, theme }) {
12 21 const [q, setQ] = useState('')
13 22 const [cat, setCat] = useState('All')
23 + const [sort, setSort] = useState('category')
24 + const [hover, setHover] = useState(null) // id under the pointer / focus
25 + const [pinned, setPinned] = useState(null) // id pinned with ⓘ (mobile: detail mode)
14 26 const inputRef = useRef(null)
15 − useEffect(() => { if (open) { setQ(''); setCat(favorites.length ? 'Favorites' : 'All') } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps
27 + const isMobile = useMediaQuery('(max-width: 760px)')
28 + const entries = useCatalog(open ? 'indicator' : null)
29 + useEffect(() => { if (open) { setQ(''); setCat(favorites.length ? 'Favorites' : 'All'); setPinned(null); setHover(null) } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps
16 30 const all = useMemo(() => catalog(), [open]) // eslint-disable-line react-hooks/exhaustive-deps
17 31 const cats = ['All', ...CATEGORIES.filter(c => c === 'Favorites' ? favorites.length > 0 : all.some(d => d.category === c))]
18 − const list = searchCatalog(q, q ? 'All' : cat, favorites)
19 32 const counts = useMemo(() => { const m = {}; for (const i of active) m[i.type] = (m[i.type] || 0) + 1; return m }, [active])
20 33
34 + // list = page catalog filtered by tab, then by the query (encyclopedia text when loaded), then sorted
35 + const list = useMemo(() => {
36 + let base = searchCatalog('', q ? 'All' : cat, favorites)
37 + if (q) {
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)
40 + base = hits.map(h => byId.get(h.id)).filter(Boolean)
41 + return base
42 + }
43 + const withCat = base.map(d => ({ ...d, category: entries?.[d.id]?.category || d.category.toLowerCase() }))
44 + return sortEntries(withCat, sort, favorites).map(d => base.find(x => x.id === d.id))
45 + }, [q, cat, sort, favorites, entries, all]) // eslint-disable-line react-hooks/exhaustive-deps
46 +
47 + const detailId = pinned || hover || list[0]?.id || null
48 + const detail = detailId ? entries?.[detailId] : null
49 + const detailDef = detailId ? all.find(d => d.id === detailId) : null
50 +
21 51 const onKey = e => {
22 − if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
52 + if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
23 53 const items = [...(e.currentTarget.querySelectorAll('[data-lib-item]') || [])]
24 54 if (!items.length) return
25 − e.preventDefault()
26 55 const i = items.indexOf(document.activeElement)
27 − const next = e.key === 'ArrowDown' ? items[Math.min(items.length - 1, i + 1)] : items[Math.max(0, i - 1)]
56 + if (i < 0 && e.key !== 'ArrowDown') return
57 + e.preventDefault()
58 + const cols = Number(getComputedStyle(e.currentTarget.querySelector('.ch-lib-list') || e.currentTarget).getPropertyValue('--cols')) || 1
59 + const step = e.key === 'ArrowDown' ? cols : e.key === 'ArrowUp' ? -cols : e.key === 'ArrowRight' ? 1 : -1
60 + const next = items[Math.max(0, Math.min(items.length - 1, i < 0 ? 0 : i + step))]
28 61 next?.focus()
29 62 }
30 63
64 + const add = id => { onAdd(id); if (isMobile) setPinned(null) }
65 + const oneLiner = d => entries?.[d.id]?.oneLiner || d.description || `${d.category} · ${d.pane === 'main' ? 'overlay' : 'own pane'}`
66 +
67 + const mobileDetail = isMobile && pinned && detail
31 68 return (
32 − <Dialog open={open} onClose={onClose} title="Indicators" testId="ch-library" size="lg" initialFocus={inputRef}>
33 − <div className="ch-lib">
34 − <div className="ch-lib-main" onKeyDown={onKey}>
35 − <div className="ch-lib-search">
36 − <Input ref={inputRef} type="search" placeholder="Search indicators… (name, id, description)" value={q} onChange={e => setQ(e.target.value)} aria-label="Search indicators" data-testid="ch-lib-search" />
69 + <Dialog open={open} onClose={onClose} title={mobileDetail ? detail.name : 'Indicators'} testId="ch-library" size="xl" initialFocus={inputRef}>
70 + {mobileDetail ? (
71 + <div className="ch-lib-mobile-detail">
72 + <button type="button" className="btn btn-sm btn-ghost" onClick={() => setPinned(null)} data-testid="ch-lib-back">‹ Back to the library</button>
73 + <IndicatorDetail entry={detail} def={detailDef} onAdd={add} counts={counts} />
74 + </div>
75 + ) : (
76 + <div className="ch-lib">
77 + <div className="ch-lib-main" onKeyDown={onKey}>
78 + <div className="ch-lib-search">
79 + <Input ref={inputRef} type="search" placeholder="Search indicators… (name, abbreviation, what it does)" value={q} onChange={e => setQ(e.target.value)} aria-label="Search indicators" data-testid="ch-lib-search" />
80 + <Select value={sort} onChange={e => setSort(e.target.value)} aria-label="Sort indicators" data-testid="ch-lib-sort">{SORTS.map(([id, l]) => <option key={id} value={id}>{l}</option>)}</Select>
81 + </div>
82 + {!q && (
83 + <div className="ch-lib-tabs" role="tablist" aria-label="Categories">
84 + {cats.map(c => <button key={c} type="button" role="tab" aria-selected={cat === c} className="ch-lib-tab" onClick={() => setCat(c)} data-testid={`ch-lib-cat-${c.replace(/\W/g, '').toLowerCase()}`}>{c}{c === 'Favorites' ? ` (${favorites.length})` : ''}</button>)}
85 + </div>
86 + )}
87 + <div className="ch-lib-count muted small" aria-live="polite">{list.length} of {all.length} indicators{q ? ` match “${q}”` : ''}</div>
88 + <ul className="ch-lib-list is-cards" role="list" data-testid="ch-lib-list" onMouseLeave={() => setHover(null)}>
89 + {list.map(d => (
90 + <li key={d.id} className={`ch-card ${detailId === d.id ? 'is-current' : ''}`} data-testid={`ch-lib-card-${d.id}`} onMouseEnter={() => setHover(d.id)}>
91 + <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 + <Thumb kind="indicator" id={d.id} size="sm" className="ch-card-thumb" />
93 + <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-desc" id={`ch-card-desc-${d.id}`}>{oneLiner(d)}</span>
96 + </span>
97 + </button>
98 + <span className="ch-card-side">
99 + <span className="ch-lib-meta">{d.category}</span>
100 + <button type="button" className={`ch-lib-star ${favorites.includes(d.id) ? 'is-on' : ''}`} aria-pressed={favorites.includes(d.id)} aria-label={favorites.includes(d.id) ? `Remove ${d.name} from favourites` : `Add ${d.name} to favourites`} onClick={() => onToggleFavorite(d.id)} data-testid={`ch-lib-fav-${d.id}`}><StarIcon filled={favorites.includes(d.id)} /></button>
101 + <button type="button" className={`ch-lib-star ch-lib-info ${pinned === d.id ? 'is-on' : ''}`} aria-pressed={pinned === d.id} aria-label={`About ${d.name}`} onClick={() => setPinned(p => (p === d.id && !isMobile ? null : d.id))} data-testid={`ch-lib-info-${d.id}`}><InfoIcon /></button>
102 + </span>
103 + </li>
104 + ))}
105 + {!list.length && <li className="ch-lib-empty muted small">No indicator matches “{q}”.</li>}
106 + </ul>
37 107 </div>
38 − {!q && (
39 − <div className="ch-lib-tabs" role="tablist" aria-label="Categories">
40 − {cats.map(c => <button key={c} type="button" role="tab" aria-selected={cat === c} className="ch-lib-tab" onClick={() => setCat(c)} data-testid={`ch-lib-cat-${c.replace(/\W/g, '').toLowerCase()}`}>{c}{c === 'Favorites' ? ` (${favorites.length})` : ''}</button>)}
108 + <aside className="ch-lib-side" aria-label="Indicator details and active indicators">
109 + {!isMobile && (
110 + <div className="ch-lib-detail" data-testid="ch-lib-detail">
111 + {detail ? <IndicatorDetail entry={detail} def={detailDef} onAdd={add} counts={counts} compact /> : <p className="muted small" style={{ margin: 0 }} aria-busy={!entries}>{entries ? 'Hover or focus an indicator to read about it.' : 'Loading the encyclopedia…'}</p>}
112 + </div>
113 + )}
114 + <div className="ch-lib-active">
115 + <div className="ch-lib-active-title">Active ({active.length})</div>
116 + {active.length === 0 && <p className="muted small" style={{ margin: 0 }}>Nothing yet — click an indicator to add it.</p>}
117 + <ul role="list" className="ch-lib-active-list" data-testid="ch-lib-active">
118 + {active.map((ind, i) => (
119 + <li key={ind.id} className={`ch-lib-active-row ${ind.hidden ? 'is-hidden' : ''}`} data-testid="ch-lib-active-row">
120 + <span className="ch-swatch" style={{ background: theme ? seriesColor(theme, ind.colorIndex ?? 0) : undefined }} aria-hidden="true" />
121 + <span className="ch-lib-active-name">{indicatorShortLabel(ind)}</span>
122 + <span className="ch-lib-active-actions">
123 + <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} up`} disabled={i === 0} onClick={() => onMove(ind.id, -1)}><UpIcon /></button>
124 + <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} down`} disabled={i === active.length - 1} onClick={() => onMove(ind.id, 1)}><DownIcon /></button>
125 + <button type="button" className="ch-legend-x" aria-pressed={!!ind.hidden} aria-label={ind.hidden ? `Show ${indicatorShortLabel(ind)}` : `Hide ${indicatorShortLabel(ind)}`} onClick={() => onToggleHidden(ind.id)}>{ind.hidden ? <EyeOffIcon /> : <EyeIcon />}</button>
126 + <button type="button" className="ch-legend-x" aria-label={`Settings of ${indicatorShortLabel(ind)}`} onClick={() => onOpenSettings(ind.id)}><GearIcon /></button>
127 + <button type="button" className="ch-legend-x" aria-label={`Remove ${indicatorShortLabel(ind)}`} onClick={() => onRemove(ind.id)}><CloseIcon /></button>
128 + </span>
129 + </li>
130 + ))}
131 + </ul>
41 132 </div>
42 − )}
43 − <ul className="ch-lib-list" role="list" data-testid="ch-lib-list">
44 − {list.map(d => (
45 − <li key={d.id} className="ch-lib-item">
46 − <button type="button" className="ch-lib-add" data-lib-item onClick={() => onAdd(d.id)} data-testid={`ch-lib-add-${d.id}`} aria-label={`Add ${d.name}`}>
47 − <span className="ch-lib-name">{d.name}{counts[d.id] ? <span className="ch-lib-count">×{counts[d.id]}</span> : null}</span>
48 − <span className="ch-lib-desc">{d.description || `${d.category} · ${d.pane === 'main' ? 'overlay' : 'own pane'}`}</span>
49 − </button>
50 − <span className="ch-lib-meta">{d.category}</span>
51 − <button type="button" className={`ch-lib-star ${favorites.includes(d.id) ? 'is-on' : ''}`} aria-pressed={favorites.includes(d.id)} aria-label={favorites.includes(d.id) ? `Remove ${d.name} from favourites` : `Add ${d.name} to favourites`} onClick={() => onToggleFavorite(d.id)} data-testid={`ch-lib-fav-${d.id}`}><StarIcon filled={favorites.includes(d.id)} /></button>
52 − </li>
53 − ))}
54 − {!list.length && <li className="ch-lib-empty muted small">No indicator matches “{q}”.</li>}
55 − </ul>
133 + </aside>
56 134 </div>
57 − <aside className="ch-lib-active" aria-label="Active indicators">
58 − <div className="ch-lib-active-title">Active ({active.length})</div>
59 − {active.length === 0 && <p className="muted small" style={{ margin: 0 }}>Nothing yet — click an indicator to add it.</p>}
60 − <ul role="list" className="ch-lib-active-list" data-testid="ch-lib-active">
61 − {active.map((ind, i) => (
62 − <li key={ind.id} className={`ch-lib-active-row ${ind.hidden ? 'is-hidden' : ''}`} data-testid="ch-lib-active-row">
63 − <span className="ch-swatch" style={{ background: theme ? seriesColor(theme, ind.colorIndex ?? 0) : undefined }} aria-hidden="true" />
64 − <span className="ch-lib-active-name">{indicatorShortLabel(ind)}</span>
65 − <span className="ch-lib-active-actions">
66 − <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} up`} disabled={i === 0} onClick={() => onMove(ind.id, -1)}><UpIcon /></button>
67 − <button type="button" className="ch-legend-x" aria-label={`Move ${indicatorShortLabel(ind)} down`} disabled={i === active.length - 1} onClick={() => onMove(ind.id, 1)}><DownIcon /></button>
68 − <button type="button" className="ch-legend-x" aria-pressed={!!ind.hidden} aria-label={ind.hidden ? `Show ${indicatorShortLabel(ind)}` : `Hide ${indicatorShortLabel(ind)}`} onClick={() => onToggleHidden(ind.id)}>{ind.hidden ? <EyeOffIcon /> : <EyeIcon />}</button>
69 − <button type="button" className="ch-legend-x" aria-label={`Settings of ${indicatorShortLabel(ind)}`} onClick={() => onOpenSettings(ind.id)}><GearIcon /></button>
70 − <button type="button" className="ch-legend-x" aria-label={`Remove ${indicatorShortLabel(ind)}`} onClick={() => onRemove(ind.id)}><CloseIcon /></button>
71 − </span>
72 − </li>
73 − ))}
74 − </ul>
75 − </aside>
76 − </div>
135 + )}
77 136 </Dialog>
78 137 )
79 138 }
modified hfmarketdata/web/src/pages/charts/IndicatorSettings.jsx +13 −4
@@ -1,6 +1,7 @@
1 1 // Indicator settings dialog (double-click on the legend row or its ⚙): Inputs (form generated from the catalog
2 −// `inputs`), Style (colour / width / line style / visibility per plot, levels), Visibility (per timeframe).
3 −// Every change is applied live to the chart through `onChange`; "Reset defaults" restores the catalog values.
2 +// `inputs`), Style (colour / width / line style / visibility per plot, levels), Visibility (per timeframe), About
3 +// (the full encyclopedia sheet, lazy chunk). Every change is applied live to the chart through `onChange`;
4 +// "Reset defaults" restores the catalog values.
4 5 import React, { useEffect, useState } from 'react'
5 6 import Dialog from './Dialog.jsx'
6 7 import { Input, Select } from '../../components/Field.jsx'
@@ -8,8 +9,10 @@ import { TIMEFRAMES, TF_LABEL } from '../../charts/data/bars.js'
8 9 import { defaultParams, indicatorDef, indicatorShortLabel } from './indicators.js'
9 10 import { DRAW_COLORS, LINE_STYLES, LINE_WIDTHS } from './drawtools.js'
10 11 import { seriesColor } from './theme.js'
12 +import { useCatalog } from '../../charts/catalog/ChartFigure.jsx'
13 +import IndicatorDetail from './IndicatorDetail.jsx'
11 14
12 −const TABS = [['inputs', 'Inputs'], ['style', 'Style'], ['visibility', 'Visibility']]
15 +const TABS = [['inputs', 'Inputs'], ['style', 'Style'], ['visibility', 'Visibility'], ['about', 'About']]
13 16
14 17 function InputField({ inp, value, onChange }) {
15 18 const id = `ch-inp-${inp.name}`
@@ -32,6 +35,7 @@ function InputField({ inp, value, onChange }) {
32 35 export default function IndicatorSettings({ open, ind, plotColors, theme, onChange, onClose, onRemove }) {
33 36 const [tab, setTab] = useState('inputs')
34 37 useEffect(() => { if (open) setTab('inputs') }, [open, ind?.id])
38 + const entries = useCatalog(open && tab === 'about' ? 'indicator' : null)
35 39 const def = ind ? indicatorDef(ind.type) : null
36 40 if (!open || !ind || !def) return null
37 41 const set = patch => onChange(ind.id, patch)
@@ -58,7 +62,12 @@ export default function IndicatorSettings({ open, ind, plotColors, theme, onChan
58 62 <div className="ch-set-form" role="tabpanel" data-testid="ch-ind-inputs">
59 63 {def.inputs.map(inp => <InputField key={inp.name} inp={inp} value={ind.params?.[inp.name]} onChange={v => setParam(inp.name, v)} />)}
60 64 {!def.inputs.length && <p className="muted small" style={{ margin: 0 }}>This indicator has no inputs.</p>}
61 − {def.description && <p className="muted small ch-set-desc">{def.description}</p>}
65 + {def.description && <p className="muted small ch-set-desc">{def.description} <button type="button" className="ch-link-btn" onClick={() => setTab('about')} data-testid="ch-ind-about-link">Why these defaults?</button></p>}
66 + </div>
67 + )}
68 + {tab === 'about' && (
69 + <div className="ch-set-form" role="tabpanel" data-testid="ch-ind-about">
70 + {entries?.[ind.type] ? <IndicatorDetail entry={entries[ind.type]} def={def} /> : <p className="muted small" aria-busy="true" style={{ margin: 0 }}>{entries ? 'No encyclopedia entry for this indicator.' : 'Loading…'}</p>}
62 71 </div>
63 72 )}
64 73 {tab === 'style' && (
modified hfmarketdata/web/src/pages/charts/Legend.jsx +23 −2
@@ -9,6 +9,21 @@ import { formatPrice, formatStampLabel, formatVolume } from '../../charts/data/s
9 9 import { indicatorDef, indicatorShortLabel } from './indicators.js'
10 10 import { EyeIcon, EyeOffIcon, GearIcon } from './icons.jsx'
11 11 import { seriesColor } from './theme.js'
12 +import { loadIndicators } from '../../charts/catalog/index.js'
13 +import { interpret } from '../../charts/catalog/interpret.js'
14 +
15 +/** Hover tooltip of a legend row: full name, one-liner from the encyclopedia and the current values explained. */
16 +function IndicatorTip({ ind, def, entry, vals, bar }) {
17 + const lines = interpret(def, vals, bar)
18 + return (
19 + <div className="ch-legend-tip" role="tooltip" data-testid="ch-legend-tip">
20 + <strong>{entry?.name || def?.name || ind.type}</strong>
21 + {entry?.oneLiner && <p>{entry.oneLiner}</p>}
22 + {lines.length > 0 && <ul>{lines.map((l, i) => <li key={i}>{l}</li>)}</ul>}
23 + <span className="muted">Double-click for settings</span>
24 + </div>
25 + )
26 +}
12 27
13 28 const fmtVal = (v, d) => (v == null || !Number.isFinite(v) ? '—' : formatPrice(v, typeof d === 'number' ? Math.min(d, 4) : 'auto'))
14 29
@@ -21,6 +36,11 @@ export default function Legend({ cs, name, hover, lastBar, prevBar, indValues, c
21 36 const dir = delta == null ? 'neutral' : delta >= 0 ? 'up' : 'down'
22 37 const stamp = bar ? formatStampLabel(bar.t, cs.tf, cs.tf === '1day' ? '' : tz) : ''
23 38
39 + // encyclopedia tooltip on the indicator name (chunk loaded on first hover only)
40 + const [tip, setTip] = useState(null) // indicator id
41 + const [entries, setEntries] = useState(null)
42 + const showTip = id => { setTip(id); if (!entries) loadIndicators().then(setEntries, () => setEntries({})) }
43 +
24 44 // throttled live text (≤ 1 update / 600 ms)
25 45 const [live, setLive] = useState('')
26 46 const liveRef = useRef({ t: 0, timer: 0 })
@@ -64,9 +84,10 @@ export default function Legend({ cs, name, hover, lastBar, prevBar, indValues, c
64 84 const keys = Object.keys(vals).length ? Object.keys(vals) : (def?.outputs || ['value'])
65 85 const label = indicatorShortLabel(ind)
66 86 return (
67 − <div key={ind.id} className={`ch-legend-row ${ind.hidden ? 'is-hidden' : ''}`} data-testid="ch-legend-ind" onDoubleClick={() => onOpenSettings(ind.id)}>
87 + <div key={ind.id} className={`ch-legend-row ${ind.hidden ? 'is-hidden' : ''} ${tip === ind.id ? 'is-tip' : ''}`} data-testid="ch-legend-ind" onDoubleClick={() => onOpenSettings(ind.id)} onMouseLeave={() => setTip(t => (t === ind.id ? null : t))}>
68 88 <span className="ch-swatch" style={{ background: color }} aria-hidden="true" />
69 − <button type="button" className="ch-legend-btn" onClick={() => onOpenSettings(ind.id)} title="Settings (double-click)">{label}</button>
89 + <button type="button" className="ch-legend-btn" onClick={() => onOpenSettings(ind.id)} onMouseEnter={() => showTip(ind.id)} onFocus={() => showTip(ind.id)} onBlur={() => setTip(t => (t === ind.id ? null : t))} aria-describedby={tip === ind.id ? `ch-legend-tip-${ind.id}` : undefined}>{label}</button>
90 + {tip === ind.id && !compact && <div id={`ch-legend-tip-${ind.id}`} className="ch-legend-tip-wrap"><IndicatorTip ind={ind} def={def} entry={entries?.[ind.type]} vals={vals} bar={bar} /></div>}
70 91 {!ind.hidden && (
71 92 <span className="ch-legend-vals mono">
72 93 {keys.map(k => <span key={k} style={{ color: ind.plots?.[k]?.color || colors[k] || undefined }}>{keys.length > 1 && <i>{k} </i>}{fmtVal(vals[k], decimals)}</span>)}
modified hfmarketdata/web/src/pages/charts/charts.css +100 −0
@@ -323,3 +323,103 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
323 323 .ch-layout-2h .ch-grid, .ch-layout-4 .ch-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 1fr; }
324 324 .ch-flyout { display: none; }
325 325 }
326 +
327 +/* ---- encyclopedia: library cards, detail panel, tooltips, intro strip (thumbnails rendered by our engine) -------- */
328 +.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); }
331 +.ch-lib-search { display: flex; gap: 8px; align-items: center; }
332 +.ch-lib-search .select { width: 120px; flex: none; }
333 +.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; }
335 +.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 +.ch-card:hover, .ch-card.is-current { border-color: var(--line-3); background: var(--bg-2); }
337 +.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%; }
339 +.ch-card-thumb { border-radius: 4px; }
340 +.ch-card-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
341 +.ch-card .ch-lib-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; line-height: 1.25; }
342 +.ch-card .ch-lib-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
343 +.ch-lib-count-badge { padding: 0 6px; border-radius: 999px; background: var(--accent-soft); color: var(--accent-strong); font-size: 10px; font-weight: 600; line-height: 16px; }
344 +[data-theme="light"] .ch-lib-count-badge { color: var(--accent); }
345 +.ch-card-side { position: absolute; left: 8px; right: 4px; bottom: 2px; display: flex; align-items: center; gap: 2px; pointer-events: none; }
346 +.ch-card-side > * { pointer-events: auto; }
347 +.ch-card-side .ch-lib-meta { flex: 1; }
348 +.ch-card-side .ch-lib-star { width: 28px; height: 28px; }
349 +.ch-card-side .ch-lib-star svg { width: 15px; height: 15px; }
350 +.ch-lib-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; border-left: 1px solid var(--line); padding-left: 16px; max-height: calc(min(86vh, 860px) - 110px); }
351 +.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 +.ch-lib-detail { flex: 1; min-height: 0; overflow: auto; padding-right: 4px; scrollbar-width: thin; }
353 +.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 +.ch-lib-mobile-detail { display: grid; gap: 10px; }
356 +.ch-link-btn { border: 0; background: transparent; color: var(--accent); font: inherit; font-size: inherit; padding: 0; cursor: pointer; text-decoration: underline; }
357 +
358 +/* detail sheet */
359 +.ch-detail { display: grid; gap: 8px; font-size: var(--fs-1); color: var(--fg-1); }
360 +.ch-detail h4 { margin: 10px 0 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); }
361 +.ch-detail-title { margin: 0; font-size: var(--fs-3); color: var(--fg); }
362 +.ch-detail-head { display: grid; gap: 6px; }
363 +.ch-detail-tags { display: flex; flex-wrap: wrap; gap: 4px; }
364 +.ch-tag { padding: 0 6px; border-radius: 4px; background: var(--bg-2); color: var(--fg-2); font-size: 11px; line-height: 18px; }
365 +.ch-tag.is-on { background: var(--accent-soft); color: var(--accent-strong); }
366 +[data-theme="light"] .ch-tag.is-on { color: var(--accent); }
367 +.ch-detail-lead { margin: 0; font-weight: 600; color: var(--fg); }
368 +.ch-detail-text { margin: 0; line-height: 1.45; }
369 +.ch-detail-text.small { font-size: var(--fs-0); color: var(--fg-2); }
370 +.ch-detail-actions { display: flex; flex-wrap: wrap; gap: 6px; }
371 +.ch-detail-formula { margin: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg); font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow: auto; color: var(--fg); }
372 +.ch-detail-vars, .ch-detail-defaults { margin: 0; display: grid; grid-template-columns: max-content 1fr; gap: 3px 10px; font-size: var(--fs-0); }
373 +.ch-detail-vars dt { color: var(--fg); }
374 +.ch-detail-vars dd, .ch-detail-defaults dd { margin: 0; color: var(--fg-2); line-height: 1.4; }
375 +.ch-detail-defaults { grid-template-columns: 1fr; gap: 2px; }
376 +.ch-detail-defaults dt { color: var(--fg); margin-top: 4px; }
377 +.ch-detail-defaults dt b { margin-left: 6px; color: var(--accent-strong); }
378 +[data-theme="light"] .ch-detail-defaults dt b { color: var(--accent); }
379 +.ch-detail-list { margin: 0; padding-left: 16px; display: grid; gap: 4px; line-height: 1.4; }
380 +.ch-detail-chips { display: flex; flex-wrap: wrap; gap: 6px; }
381 +.ch-detail-chips .ch-chip { cursor: pointer; }
382 +.ch-detail-chips .ch-chip:hover:not([disabled]) { border-color: var(--line-3); }
383 +.ch-detail-chips .ch-chip[disabled] { cursor: default; }
384 +.ch-detail.is-compact .ch-detail-thumb { max-height: 190px; }
385 +
386 +/* tool tooltips (flyouts) + sheet rows */
387 +.ch-flyout-tip { position: absolute; left: calc(100% + 6px); top: 0; width: 300px; z-index: 1; }
388 +.ch-tooltip { display: grid; gap: 6px; padding: 10px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); font-size: var(--fs-0); color: var(--fg-1); }
389 +.ch-tooltip-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--fg); font-size: var(--fs-1); }
390 +.ch-tooltip-head kbd { font-size: 10px; color: var(--fg-3); padding: 0 5px; border: 1px solid var(--line-2); border-radius: 4px; }
391 +.ch-tooltip-lead { margin: 0; color: var(--fg-1); line-height: 1.4; }
392 +.ch-tooltip-steps { margin: 0; padding-left: 18px; display: grid; gap: 2px; color: var(--fg-2); line-height: 1.35; }
393 +.ch-tooltip-link { color: var(--accent); font-size: var(--fs-0); }
394 +.ch-sheet-tool { min-height: 64px; padding: 6px 12px 6px 6px; }
395 +.ch-sheet-thumb { width: 96px; flex: none; }
396 +.ch-sheet-tool-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
397 +.ch-sheet-tool-desc { font-size: var(--fs-0); font-weight: 400; color: var(--fg-2); line-height: 1.3; }
398 +
399 +/* legend tooltip */
400 +.ch-legend-row.is-tip { z-index: 8; }
401 +.ch-legend-tip-wrap { position: absolute; top: 100%; left: 18px; z-index: 9; padding-top: 4px; }
402 +.ch-legend-tip { width: 300px; padding: 8px 10px; display: grid; gap: 4px; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-md); box-shadow: var(--shadow-2); font-size: var(--fs-0); color: var(--fg-1); font-family: var(--sans); }
403 +.ch-legend-tip strong { color: var(--fg); }
404 +.ch-legend-tip p { margin: 0; line-height: 1.4; }
405 +.ch-legend-tip ul { margin: 0; padding-left: 16px; display: grid; gap: 2px; color: var(--fg); }
406 +.ch-legend-tip .muted { font-size: 10px; }
407 +
408 +/* first-visit strip */
409 +.ch-intro { position: absolute; left: 50%; bottom: 40px; transform: translateX(-50%); z-index: 3; display: flex; align-items: center; gap: 14px; max-width: calc(100% - 24px); padding: 8px 12px 8px 14px; background: color-mix(in srgb, var(--bg-1) 92%, transparent); border: 1px solid var(--line-2); border-radius: var(--r-lg, 12px); box-shadow: var(--shadow-2); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
410 +.ch-intro-text { display: grid; gap: 1px; font-size: var(--fs-1); color: var(--fg); white-space: nowrap; }
411 +.ch-intro-text .muted { font-size: var(--fs-0); }
412 +.ch-intro-thumbs { display: flex; gap: 8px; }
413 +.ch-intro-thumb { display: grid; gap: 3px; width: 112px; padding: 3px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; color: var(--fg-2); font: inherit; font-size: 11px; cursor: pointer; text-align: left; }
414 +.ch-intro-thumb:hover, .ch-intro-thumb:focus-visible { border-color: var(--line-3); color: var(--fg); background: var(--bg-2); }
415 +.ch-intro-thumb span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
416 +.ch-intro-link { font-size: var(--fs-0); color: var(--accent); white-space: nowrap; }
417 +.ch-intro-close { flex: none; }
418 +@media (max-width: 1100px) { .ch-lib { grid-template-columns: minmax(0, 1fr) 300px; } .ch-lib-list.is-cards { --cols: 1; } }
419 +@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 +@media (max-width: 760px) {
421 + .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; }
423 + .ch-flyout-tip { display: none; }
424 +}
425 +@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/drawtools.js +8 −7
@@ -1,5 +1,5 @@
1 1 // Drawing tools of the /charts page, grouped like a professional terminal's flyout bar. Every tool id is the engine's
2 −// `setDrawingTool(id)` value; tools the loaded engine does not list (`DRAWING_TOOLS` of engine v1) are hidden, so
2 +// `setDrawingTool(id)` value (42 tools with the v2 engine; the encyclopedia in src/charts/catalog/tools.js describes each one); tools the loaded engine does not list (`DRAWING_TOOLS` of engine v1) are hidden, so
3 3 // the bar works with the v1 engine today and shows the full arsenal with the v2 engine (additive contract).
4 4 import { DRAWING_TOOLS } from '../../charts/engine/index.js'
5 5
@@ -13,10 +13,10 @@ export const GROUPS = [
13 13 { id: 'vline', label: 'Vertical line', key: 'V' },
14 14 { id: 'cross', label: 'Cross line', key: 'C' },
15 15 { id: 'channel', label: 'Parallel channel' },
16 − { id: 'channel-parallel', label: 'Parallel channel (3 points)' },
17 16 { id: 'regression', label: 'Regression trend' },
18 17 { id: 'pitchfork', label: 'Andrews pitchfork', key: 'P' },
19 18 { id: 'schiff', label: 'Schiff pitchfork' },
19 + { id: 'mschiff', label: 'Modified Schiff pitchfork' },
20 20 ] },
21 21 { id: 'fib', label: 'Fibonacci & Gann', tools: [
22 22 { id: 'fib', label: 'Fibonacci retracement', key: 'F' },
@@ -31,10 +31,11 @@ export const GROUPS = [
31 31 { id: 'rect', label: 'Rectangle', key: 'R' },
32 32 { id: 'ellipse', label: 'Ellipse', key: 'E' },
33 33 { id: 'triangle', label: 'Triangle' },
34 − { id: 'polyline', label: 'Polyline' },
34 + { id: 'path', label: 'Path (polyline)' },
35 35 { id: 'brush', label: 'Brush', key: 'B' },
36 36 { id: 'arrow', label: 'Arrow', key: 'A' },
37 − { id: 'arrow-marker', label: 'Arrow marker' },
37 + { id: 'arrow-up', label: 'Arrow up marker' },
38 + { id: 'arrow-down', label: 'Arrow down marker' },
38 39 ] },
39 40 { id: 'text', label: 'Text & notes', tools: [
40 41 { id: 'text', label: 'Text', key: 'X' },
@@ -47,9 +48,9 @@ export const GROUPS = [
47 48 { id: 'price-range', label: 'Price range' },
48 49 { id: 'date-range', label: 'Date range' },
49 50 { id: 'date-price-range', label: 'Date & price range' },
50 − { id: 'vertical-range', label: 'Vertical range' },
51 − { id: 'long-position', label: 'Long position', key: 'L' },
52 − { id: 'short-position', label: 'Short position', key: 'S' },
51 + { id: 'vrange', label: 'Vertical range' },
52 + { id: 'long', label: 'Long position', key: 'L' },
53 + { id: 'short', label: 'Short position', key: 'S' },
53 54 ] },
54 55 { id: 'patterns', label: 'Patterns', tools: [
55 56 { id: 'elliott-impulse', label: 'Elliott impulse wave (12345)' },
modified hfmarketdata/web/src/pages/charts/icons.jsx +4 −3
@@ -33,6 +33,7 @@ export const PolylineIcon = p => <I {...p}><path d="M3 15 7 6l4 7 3-9 3 8" /><Do
33 33 export const BrushIcon = p => <I {...p}><path d="M3 16c3 0 3-3 5-4s3 1 5-1 3-5 4-7" /></I>
34 34 export const ArrowIcon = p => <I {...p}><path d="M4 16 16 4M9 4h7v7" /></I>
35 35 export const ArrowMarkerIcon = p => <I {...p}><path d="M10 3v12M5 10l5 5 5-5" /></I>
36 +export const ArrowUpMarkerIcon = p => <I {...p}><path d="M10 17V5M5 10l5-5 5 5" /></I>
36 37 export const TextIcon = p => <I {...p}><path d="M4 5h12M10 5v11M7 16h6" /></I>
37 38 export const CalloutIcon = p => <I {...p}><path d="M3 4h14v8h-7l-3 4v-4H3z" /></I>
38 39 export const PriceLabelIcon = p => <I {...p}><path d="M3 7h9l4 3-4 3H3z" /><path d="M6 10h4" /></I>
@@ -103,10 +104,10 @@ export const SERIES_ICONS = { candles: CandlesIcon, hollow: HollowIcon, ohlc: Oh
103 104 export const LAYOUT_ICONS = { '1': Layout1Icon, '2h': Layout2hIcon, '2v': Layout2vIcon, '4': Layout4Icon }
104 105 export const TOOL_ICONS = {
105 106 trendline: TrendlineIcon, ray: RayIcon, extended: ExtendedIcon, hray: HRayIcon, hline: HLineIcon, vline: VLineIcon, cross: CrossIcon,
106 − channel: ChannelIcon, 'channel-parallel': Channel3Icon, regression: RegressionIcon, pitchfork: PitchforkIcon, schiff: SchiffIcon,
107 + channel: Channel3Icon, regression: RegressionIcon, pitchfork: PitchforkIcon, schiff: SchiffIcon, mschiff: SchiffIcon,
107 108 fib: FibIcon, 'fib-extension': FibExtIcon, 'fib-timezones': FibTimeIcon, 'fib-fan': FibFanIcon, 'fib-arcs': FibArcsIcon, 'gann-fan': GannFanIcon, 'gann-box': GannBoxIcon,
108 − rect: RectIcon, ellipse: EllipseIcon, triangle: TriangleIcon, polyline: PolylineIcon, brush: BrushIcon, arrow: ArrowIcon, 'arrow-marker': ArrowMarkerIcon,
109 + rect: RectIcon, ellipse: EllipseIcon, triangle: TriangleIcon, path: PolylineIcon, brush: BrushIcon, arrow: ArrowIcon, 'arrow-up': ArrowUpMarkerIcon, 'arrow-down': ArrowMarkerIcon,
109 110 text: TextIcon, callout: CalloutIcon, 'price-label': PriceLabelIcon, flag: FlagIcon,
110 − measure: MeasureIcon, 'price-range': PriceRangeIcon, 'date-range': DateRangeIcon, 'date-price-range': DatePriceRangeIcon, 'vertical-range': VerticalRangeIcon, 'long-position': LongIcon, 'short-position': ShortIcon,
111 + measure: MeasureIcon, 'price-range': PriceRangeIcon, 'date-range': DateRangeIcon, 'date-price-range': DatePriceRangeIcon, vrange: VerticalRangeIcon, long: LongIcon, short: ShortIcon,
111 112 'elliott-impulse': ElliottIcon, 'elliott-correction': ElliottCorrIcon, xabcd: XabcdIcon, 'head-shoulders': HeadShouldersIcon,
112 113 }
113 114