// Live instrument detail screen — variant #3 (dual hero + dual-column depth)
// Tabs: Depth · Chart (candles) · Spec. Fixed SELL/BUY across all tabs.
// 3-part price (base · significant pips · fractional), top-of-book sized
function DPxLive({ value, inst, size = 30, color, weight = 700 }) {
const { base, pips, frac } = splitPip(value, inst.digits, inst.pipDigits);
return (
// A 3-part price is three ADJACENT spans, which is exactly the shape
// RTL reorders: 1.08|57|4 renders as 4|57|1.08 — an unreadable price,
// not a cosmetic difference. Pinning the wrapper to LTR keeps the
// parts in order; the surrounding row still mirrors normally (ID5).
{base}{pips}
{frac && {frac}}
);
}
const DET_CAT_COLOR = { FX: '#3B82F6', Metals: '#E0A21A', Energy: '#E2563B', Indices: '#8B5CF6', Crypto: '#F7931A' };
function detVol(v) { return v.toFixed(2); }
const CHART_TFS = ['1m', '5m', '15m', '1H', '4H', '1D'];
// Timeframe codes are the values the chart, the candle request and the cell
// state all compare against — they stay canonical. Only the chip's LABEL is
// localized, looked up at render time (a t() call out here would run before
// initI18n() resolves). MT platforms localize these, so the regional teams get
// the choice; the en values are the codes themselves.
const TF_LABEL_KEY = {
'1m': 'chart.tf.m1', '5m': 'chart.tf.m5', '15m': 'chart.tf.m15',
'1H': 'chart.tf.h1', '4H': 'chart.tf.h4', '1D': 'chart.tf.d1',
};
// Real OHLC from the candle store (ts_candles via /api/instruments/candles);
// 1m aggregated live from the base feed, higher TFs rolled up server-side.
function CandleChart({ inst, ps, theme }) {
const [tf, setTf] = React.useState('1m');
const [candles, setCandles] = React.useState(null); // null = loading
React.useEffect(() => {
let alive = true;
const load = () => apiCandles(inst, tf, 60)
.then(res => { if (alive) setCandles(res.candles || []); })
.catch(() => { if (alive) setCandles([]); });
setCandles(null);
load();
const id = setInterval(load, 5000); // forming candle updates live
return () => { alive = false; clearInterval(id); };
}, [inst.sym, tf]);
const tfBar = (
);
}
const bids = book.bids, asks = book.asks;
const max = Math.max(...bids.map(l => l.vol), ...asks.map(l => l.vol), 0.01);
const buyBar = theme.buy + '33', sellBar = theme.sell + '33';
const col = (lvl, isAsk) => {
const w = (lvl.vol / max) * 100;
const c = isAsk ? theme.sell : theme.buy;
const bar = isAsk ? sellBar : buyBar;
return (
// Physical left/right throughout, deliberately: the ladder is pinned LTR
// by the dir="ltr" wrapper below, so "left" and "right" mean what they
// say here and logical properties would flip back out of the pin.
);
};
return (
// ——— the depth ladder does NOT mirror (docs/i18n.md ID5/IO3) ———
// Bids on the left growing right, asks on the right growing left, prices
// meeting at the spread: one shape every desk reads the same way, and the
// bars are a spatial encoding of size rather than decoration. Mirroring
// would swap the two sides and flip every bar away from its price. Pinned
// LTR here so the whole subtree keeps physical geometry; the Arabic column
// headers still shape right-to-left inside their own cells, because
// Arabic is strong-RTL within its own run.
{t('book.col.bids')}
{t('book.col.asks')}
{bids.map(l => col(l, false))}
{asks.map(l => col(l, true))}
);
}
// Real contract specification (M6): instrument master + account leverage +
// group swap points from the Backoffice. Absent values render as an em dash —
// nothing is invented.
function SpecSheet({ inst, theme }) {
const [spec, setSpec] = React.useState(null);
React.useEffect(() => {
let alive = true;
apiSpec(inst).then(s => { if (alive) setSpec(s); }).catch(() => { if (alive) setSpec({}); });
return () => { alive = false; };
}, [inst.sym]);
if (spec === null) {
return
{t('spec.loading')}
;
}
const dash = '—';
const base = inst.backend.includes('/') ? inst.backend.split('/')[0] : inst.sym;
const contract = spec.contractSize ? spec.contractSize.toLocaleString('en-US') + ' ' + base : dash;
const marginCalc = spec.marginStrategy === 'without-leverage' ? t('spec.marginCalc.fullNotionalNoLeverage') : t('spec.marginCalc.accountLeverage');
const leverage = spec.leverage ? '1:' + spec.leverage : dash;
const marginPct = spec.marginPct ? (parseFloat(spec.marginPct) * 100).toFixed(0) + '%' : dash;
// The L/S swap legs are one key each rather than a translated letter glued to
// a number — the letter and the value have to stay one phrase to reorder.
const swaps = (spec.swapLong != null || spec.swapShort != null)
? {t('spec.swap.long', { value: spec.swapLong != null ? spec.swapLong : dash })}{t('spec.swap.short', { value: spec.swapShort != null ? spec.swapShort : dash })}
: dash;
const rows = [
[t('spec.marginCalc.label'), marginCalc],
[t('spec.accountLeverage'), leverage],
[t('spec.marginPct'), marginPct],
[t('spec.contractSizeLot'), contract],
[t('spec.pointSize'), spec.tickSize ? spec.tickSize.toFixed(inst.digits) : dash],
// Three numbers joined by neutral separators, and the ORDER carries the
// whole meaning — min, step, max. Un-isolated under RTL the separators
// resolve RTL (UBA N1: a number acts as R for the neutrals beside it) and
// the run reverses to "100.00 / 0.01 / 0.01" — min and max swapped. The
// value cell cannot be pinned LTR instead: other rows in the same column
// hold translated words. Same class as the chart grid picker's "R×C".
[t('spec.minStepMaxLots'), spec.minVol
? bidiIsolate(`${spec.minVol.toFixed(2)} / ${spec.volStep.toFixed(2)} / ${spec.maxVol.toFixed(2)}`)
: dash],
[t('spec.swapRatesPoints'), swaps],
[t('spec.group'), spec.group || dash],
[t('spec.tradingSession.label'), t('spec.tradingSession.value')],
];
return (
{t('spec.title')}
{rows.map(([label, value], i) => (
{label}
{value}
))}
{t('spec.footer.short')}
);
}
function InstrumentDetail({ inst, priceState, theme, onClose, onBuy, onSell, isFav, onToggleFav }) {
const [tab, setTab] = React.useState('Chart');
const [entered, setEntered] = React.useState(false);
React.useEffect(() => { const r = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(r); }, []);
const ps = priceState;
const pct = inst.pctDay; // live: api.jsx recomputes vs dayOpen on every tick
const up = pct >= 0;
const c = DET_CAT_COLOR[inst.cat] || theme.accent;
return (
{/* The first element stays the canonical English tab id — it is the
`tab` state and every branch below compares against it. Only the
second, the label, is localized. (The map parameter can no longer be
named `t`: that is the i18n function.) */}
{[['Chart', 'detail.tab.chart'], ['Depth', 'detail.tab.depth'], ['Spec', 'detail.tab.spec']].map(([v, key]) => (