ui: tighten dashboard copy and fix layout issues
This commit is contained in:
@@ -128,11 +128,14 @@ function MetricCard({
|
||||
activeIndex,
|
||||
chartHref,
|
||||
chartLabel = 'CoinGlass',
|
||||
hero = false,
|
||||
}: {
|
||||
rank: number
|
||||
label: string
|
||||
value: string
|
||||
tone?: Tone
|
||||
/** What the indicator IS, one sentence. Do NOT enumerate threshold bands
|
||||
* here — the chips below each card already list them. */
|
||||
hint: string
|
||||
summary: string
|
||||
thresholds: Array<{ label: string; tone?: Tone }>
|
||||
@@ -143,31 +146,54 @@ function MetricCard({
|
||||
* the dual "Data source + Chart" pattern confused users about which to click. */
|
||||
chartHref: string
|
||||
chartLabel?: string
|
||||
/** Horizontal headline layout for a single full-width card (Valuation).
|
||||
* The stacked layout stretched across the panel left a dead middle. */
|
||||
hero?: boolean
|
||||
}) {
|
||||
const head = (
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
)
|
||||
const chips = (
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</ThresholdChip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const action = (
|
||||
<div className="macro-actions">
|
||||
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (hero) {
|
||||
return (
|
||||
<article className={`macro-metric-card tone-${tone} hero`}>
|
||||
{head}
|
||||
<div className="macro-hero-body">
|
||||
<div className="macro-summary">{summary}</div>
|
||||
{chips}
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`macro-metric-card tone-${tone}`}>
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
|
||||
{head}
|
||||
<div className="macro-summary">{summary}</div>
|
||||
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</ThresholdChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="macro-actions">
|
||||
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
|
||||
</div>
|
||||
{chips}
|
||||
{action}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -402,11 +428,12 @@ export default function MacroPanel() {
|
||||
<Section title="Valuation">
|
||||
<div className="macro-grid one">
|
||||
<MetricCard
|
||||
hero
|
||||
rank={1}
|
||||
label="AHR999"
|
||||
value={ind.ahr999?.toFixed(4) ?? '—'}
|
||||
tone={toneAhr(ind.ahr999)}
|
||||
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive."
|
||||
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. Lower = cheaper."
|
||||
summary={ahrSay}
|
||||
activeIndex={ahrActive}
|
||||
thresholds={[
|
||||
@@ -457,7 +484,7 @@ export default function MacroPanel() {
|
||||
label="Altcoin Season"
|
||||
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
|
||||
tone={toneAltseason(ind.altcoin_season_index)}
|
||||
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 25–60 = mixed, 60–75 = alt strength building, ≥ 75 = altseason."
|
||||
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). Higher = broader alt strength."
|
||||
summary={altSay}
|
||||
activeIndex={altActive}
|
||||
thresholds={[
|
||||
@@ -474,7 +501,7 @@ export default function MacroPanel() {
|
||||
label="BTC Dominance"
|
||||
value={fmtPct(ind.btc_dominance_pct)}
|
||||
tone={toneBtcDominance(ind.btc_dominance_pct)}
|
||||
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 45–55% = balanced, 55–65% = BTC-led / defensive, > 65% = flight to safety."
|
||||
hint="BTC's share of total crypto market cap. High dominance = defensive, capital hiding in BTC; low = alt rotation."
|
||||
summary={domSay}
|
||||
activeIndex={domActive}
|
||||
thresholds={[
|
||||
@@ -491,7 +518,7 @@ export default function MacroPanel() {
|
||||
label="ETH / BTC"
|
||||
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
|
||||
tone={toneEthBtc(ind.eth_btc_ratio)}
|
||||
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.025–0.04 = range, > 0.04 = ETH and alts gaining leadership."
|
||||
hint="Relative strength of ETH vs BTC — a proxy for broader crypto risk appetite."
|
||||
summary={ebrSay}
|
||||
activeIndex={ebrActive}
|
||||
thresholds={[
|
||||
@@ -547,7 +574,7 @@ export default function MacroPanel() {
|
||||
label="ETF Net Flow (1d)"
|
||||
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
|
||||
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
|
||||
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, −$50M to +$50M = noise, < −$200M = heavy outflow."
|
||||
hint="Yesterday's total net flow into/out of US spot BTC ETFs — a direct read on institutional demand."
|
||||
summary={etfSay}
|
||||
activeIndex={etfActive}
|
||||
thresholds={[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { TrumpPost, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
|
||||
@@ -33,6 +33,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
|
||||
const repositionMacroRef = useRef<(() => void) | null>(null)
|
||||
const fittedRef = useRef(false)
|
||||
// Chart creation is async (dynamic import). If the candles fetch wins the
|
||||
// race (common — SWR serves them instantly from cache), the data effect
|
||||
// runs while seriesRef is still null and bails; nothing re-triggers it
|
||||
// afterwards, so the chart stays EMPTY and the 2s live-tick effect then
|
||||
// paints a single lone candle via series.update(). This state flips when
|
||||
// the chart actually exists so the data effect re-runs.
|
||||
const [chartReady, setChartReady] = useState(false)
|
||||
// The live-tick effect must never run before setData() — updating an empty
|
||||
// series creates a one-candle chart.
|
||||
const dataLoadedRef = useRef(false)
|
||||
const postsRef = useRef(posts)
|
||||
postsRef.current = posts
|
||||
const selectedPostIdRef = useRef(externalSelectedId)
|
||||
@@ -111,7 +121,11 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: 360,
|
||||
// Follow the container, don't hardcode: .chart-wrap is 360px on
|
||||
// desktop but 300/240px behind !important media queries. A fixed
|
||||
// 360 overflows those and overflow:hidden cuts off the bottom of
|
||||
// the chart — lowest candles, markers, and the whole time axis.
|
||||
height: containerRef.current.clientHeight || 360,
|
||||
layout: {
|
||||
background: { color: colors.background },
|
||||
textColor: colors.textColor,
|
||||
@@ -145,6 +159,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
seriesRef.current = series
|
||||
|
||||
// Breathing room above/below the candles. Markers render ~10px outside
|
||||
// the bar's high/low; with the default tight bottom margin a crash
|
||||
// candle at the bottom of the autoscaled range gets its marker dots
|
||||
// clipped by the pane edge.
|
||||
series.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.12, bottom: 0.16 },
|
||||
})
|
||||
|
||||
setChartReady(true)
|
||||
|
||||
// Re-project macro signal time → pixel X on every visible-range
|
||||
// change (pan / zoom / data update / resize). Without this the line
|
||||
// is set ONCE in the data effect and then stays stuck at a fixed
|
||||
@@ -226,7 +250,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
ro = new ResizeObserver(() => {
|
||||
if (containerRef.current && !destroyed) {
|
||||
chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
chart.applyOptions({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight || 360,
|
||||
})
|
||||
// Width change → new pixel coordinates → re-project the macro
|
||||
// marker too, otherwise it'd drift relative to the candles on
|
||||
// window resize / sidebar toggle.
|
||||
@@ -269,6 +296,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
ro?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
fittedRef.current = false
|
||||
dataLoadedRef.current = false
|
||||
setChartReady(false)
|
||||
if (chartRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current.remove()
|
||||
@@ -291,7 +320,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current
|
||||
const chart = chartRef.current
|
||||
if (!series || !chart || candles.length === 0) return
|
||||
if (!series || !chart || candles.length === 0) {
|
||||
dataLoadedRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const sorted = [...candles].sort((a, b) => a.time - b.time)
|
||||
|
||||
@@ -303,6 +335,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
})))
|
||||
dataLoadedRef.current = true
|
||||
|
||||
const minTime = sorted[0].time
|
||||
const maxTime = sorted[sorted.length - 1].time
|
||||
@@ -313,6 +346,13 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return t >= minTime && t <= maxTime
|
||||
})
|
||||
|
||||
// Markers show actionable signals only. Hold/filtered posts vastly
|
||||
// outnumber signals and their gray dots pile into unreadable clusters;
|
||||
// they stay browsable in the Recent-signals list instead.
|
||||
const markerPosts = visible.filter(
|
||||
(p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell',
|
||||
)
|
||||
|
||||
const bucketByTf: Record<string, number> = {
|
||||
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
|
||||
}
|
||||
@@ -320,7 +360,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
|
||||
|
||||
const bucketMap = new Map<number, typeof visible>()
|
||||
for (const p of visible) {
|
||||
for (const p of markerPosts) {
|
||||
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
||||
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
|
||||
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
|
||||
@@ -415,7 +455,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, externalSelectedId, asset])
|
||||
}, [candles, posts, externalSelectedId, asset, chartReady])
|
||||
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe, asset])
|
||||
|
||||
@@ -426,7 +466,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// the live tick exceeds them.
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current as any
|
||||
if (!series) return
|
||||
// dataLoadedRef: update() on a series that hasn't had setData() yet
|
||||
// creates a chart with a single lone candle.
|
||||
if (!series || !dataLoadedRef.current) return
|
||||
const live = livePrices[asset]
|
||||
if (live == null || !candles.length) return
|
||||
const last = candles[candles.length - 1]
|
||||
@@ -442,7 +484,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
|
||||
style={{ width: '100%', height: '100%', borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,6 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (!mounted) return null
|
||||
|
||||
const settingsHref = `/${locale}/settings#bot-config`
|
||||
const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes'
|
||||
|
||||
// Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a
|
||||
// trade. bot_engine.process_post early-returns for sys2 sources, so the
|
||||
@@ -184,7 +183,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{settingsLabel} settings ↗
|
||||
Settings ↗
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
@@ -211,8 +210,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: 'var(--ink-4)', flexShrink: 0,
|
||||
}} />
|
||||
{/* No "{s.name}" prefix — the page title right above already names
|
||||
the system; repeating it here read "Trump Signal" twice per screen. */}
|
||||
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
|
||||
{s.name} Auto-Trade
|
||||
Auto-Trade
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>
|
||||
@@ -236,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span>Settings</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -297,22 +295,20 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
{/* Header */}
|
||||
<div style={{ padding: '14px 16px', background: s.soft,
|
||||
borderLeft: `4px solid ${s.accent}` }}>
|
||||
{/* Header is just "Auto-Trade" — the page title already names the
|
||||
system, and the switch row below repeats "Auto-Trade" otherwise. */}
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
|
||||
{s.name} {isZh ? '控制面板' : '— Auto-Trade'}
|
||||
{isZh ? '控制面板' : 'Auto-Trade'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master Auto-Trade switch */}
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={ROW}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 事件交易' : '· event-driven'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
|
||||
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
|
||||
</div>
|
||||
{/* The card header above already says "Auto-Trade" — this row only
|
||||
carries the ON/OFF legend, not a second title. */}
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
|
||||
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
|
||||
@@ -377,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span>Settings</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -835,7 +835,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Take profit <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
|
||||
<span className="hint">Bot locks in profit when the position gains this much.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -851,7 +851,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
|
||||
<span className="hint">Bot cuts the loss when the position falls this much.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -998,7 +998,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily trading cap
|
||||
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
|
||||
<span className="hint">Bot stops opening new trades once it has spent this amount in a day.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
|
||||
|
||||
@@ -288,14 +288,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* P&L right after Side — it's the column this page exists for.
|
||||
As the last of 8 columns it sat off-screen on phones (the
|
||||
table scrolls inside its card) until the user swiped past
|
||||
entry/exit/hold/trigger to find it. */}
|
||||
<th>{isZh ? '来源' : 'Source'}</th>
|
||||
<th>{isZh ? '资产' : 'Asset'}</th>
|
||||
<th>{isZh ? '方向' : 'Side'}</th>
|
||||
<th>P&L</th>
|
||||
<th>{isZh ? '开仓' : 'Entry'}</th>
|
||||
<th>{isZh ? '平仓' : 'Exit'}</th>
|
||||
<th>{isZh ? '持仓' : 'Hold'}</th>
|
||||
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
|
||||
<th>P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -346,20 +350,8 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
|
||||
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||||
<td style={{ maxWidth: 260 }}>
|
||||
{t.trigger_post_text ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{t.trigger_post_text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack" style={{ alignItems: 'flex-end' }}>
|
||||
<div className="stack" style={{ alignItems: 'flex-start' }}>
|
||||
{t.pnl_usd === null || t.pnl_usd === undefined ? (
|
||||
<span
|
||||
style={{ fontSize: 12, color: 'var(--ink-4)' }}
|
||||
@@ -379,6 +371,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||||
<td style={{ maxWidth: 260 }}>
|
||||
{t.trigger_post_text ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{t.trigger_post_text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
* If you need a paragraph, you're explaining the wrong thing.
|
||||
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
|
||||
* should disappear visually until the user actively scans for help.
|
||||
* * The tooltip uses CSS hover only — no JS positioning, no portals.
|
||||
* Trade-off: it's clipped by the nearest `overflow:hidden` ancestor.
|
||||
* Pages that need tooltips inside a `<table>` cell should add
|
||||
* `overflow: visible` to that row/td.
|
||||
* * The tooltip uses CSS hover positioning — no portals. The only JS is
|
||||
* an on-reveal measurement that clamps the bubble inside the viewport
|
||||
* (a centred bubble on an icon near the screen edge used to hang
|
||||
* off-screen, worst on phones). Trade-off: it's still clipped by the
|
||||
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
|
||||
* a `<table>` cell should add `overflow: visible` to that row/td.
|
||||
*
|
||||
* Usage:
|
||||
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
|
||||
@@ -27,6 +29,7 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useRef } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
@@ -46,8 +49,28 @@ export default function InfoTip({
|
||||
width = 240,
|
||||
iconStyle,
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
// Clamp the bubble inside the viewport when it's revealed. The bubble is
|
||||
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
|
||||
// Measured at shift=0, then the needed horizontal offset is written to the
|
||||
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
|
||||
function clampIntoViewport() {
|
||||
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
|
||||
if (!bubble) return
|
||||
bubble.style.setProperty('--tip-shift', '0px')
|
||||
const r = bubble.getBoundingClientRect()
|
||||
const vw = document.documentElement.clientWidth
|
||||
const margin = 8
|
||||
let shift = 0
|
||||
if (r.left < margin) shift = margin - r.left
|
||||
else if (r.right > vw - margin) shift = (vw - margin) - r.right
|
||||
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={`infotip infotip-${placement}`}
|
||||
// role=button is a slight overstatement but it lets screen-reader users
|
||||
// know there's a thing to tab to. The actual content is in data-tip and
|
||||
@@ -56,6 +79,9 @@ export default function InfoTip({
|
||||
tabIndex={0}
|
||||
aria-label={text}
|
||||
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
|
||||
onMouseEnter={clampIntoViewport}
|
||||
onFocus={clampIntoViewport}
|
||||
onTouchStart={clampIntoViewport}
|
||||
>
|
||||
<span aria-hidden="true" className="infotip-icon">?</span>
|
||||
<span className="infotip-bubble" role="tooltip">{text}</span>
|
||||
|
||||
Reference in New Issue
Block a user