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
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 | |