/* ═══════════════════════════════════════════════════════════════════
st-chart.jsx — SHARED PRO CHART (dashboard · coin · trade) v3
Rendering core = lightweight-charts (TradingView's open-source
engine — the same candle DNA as Bybit's chart): native wheel zoom,
drag pan, pixel-crisp candles, auto-scaling price axis, kinetic
scroll. We add: working timeframes, EMA21/55 + VWAP overlays,
volume pane, RSI pane (synced), S/R zones and order price-lines,
live OHLC readout, 30s auto-refresh pinned to the live edge.
Usage (unchanged from v2):
Requires the lightweight-charts standalone script (see index.html).
═══════════════════════════════════════════════════════════════════ */
/* Timeframe presets = candle interval + how many bars to show on first paint.
Fewer visible bars = bigger, taller candles — tuned for a Bybit-style zoom
(15m is the default and is the most zoomed-in / readable). Order here is the
order the buttons render in. */
const ST_TF = {
'1m': { iv: '1', bars: 55 }, // ~55 min, big candles
'15m': { iv: '15', bars: 50 }, // ~12 h — super zoomed (default)
'30m': { iv: '30', bars: 56 }, // ~28 h
'1h': { iv: '60', bars: 64 }, // ~2.7 d
'4h': { iv: '240', bars: 70 }, // ~11 d
'1d': { iv: 'D', bars: 75 }, // ~2.5 mo
'1w': { iv: 'W', bars: 60 }, // ~14 mo
'1M': { iv: 'M', bars: 36 }, // 3 yr of monthly
};
function stPrecision(p) {
const a = Math.abs(p || 1);
if (a >= 1000) return { precision: 1, minMove: 0.1 };
if (a >= 50) return { precision: 2, minMove: 0.01 };
if (a >= 1) return { precision: 3, minMove: 0.001 };
if (a >= 0.01) return { precision: 5, minMove: 0.00001 };
return { precision: 8, minMove: 0.00000001 };
}
function stFmtPrice(p) {
if (p == null || isNaN(p)) return '—';
const a = Math.abs(p);
if (a >= 1000) return p.toLocaleString('en-US', { maximumFractionDigits: 1 });
if (a >= 50) return p.toFixed(2);
if (a >= 1) return p.toFixed(3);
if (a >= 0.01) return p.toFixed(5);
return p.toPrecision(4);
}
function stEma(closes, period) {
const k = 2 / (period + 1);
let e = closes[0];
return closes.map(c => (e = c * k + e * (1 - k)));
}
function stVwap(cs) {
let pv = 0, vv = 0;
return cs.map(c => {
const t = (c.h + c.l + c.c) / 3;
pv += t * c.v; vv += c.v;
return vv ? pv / vv : c.c;
});
}
function stRsi(closes, period = 14) {
const out = [50];
let ag = 0, al = 0;
for (let i = 1; i < closes.length; i++) {
const d = closes[i] - closes[i - 1];
const g = Math.max(0, d), l = Math.max(0, -d);
if (i <= period) {
ag += g / period; al += l / period;
out.push(i === period ? 100 - 100 / (1 + ag / (al || 1e-9)) : 50);
} else {
ag = (ag * (period - 1) + g) / period;
al = (al * (period - 1) + l) / period;
out.push(100 - 100 / (1 + ag / (al || 1e-9)));
}
}
return out;
}
function STProChart({ sym, orders = [], supportZones = [], resistanceZones = [], defaultTf = '15m', showRSI = true }) {
const [tf, setTf] = React.useState(defaultTf);
const [ohlc, setOhlc] = React.useState(null); /* crosshair readout */
const [meta, setMeta] = React.useState(null); /* last bar + ema values for legend */
const [err, setErr] = React.useState(null);
const hostRef = React.useRef(null);
const rsiRef = React.useRef(null);
const chRef = React.useRef({}); /* charts + series + data */
/* ── build charts once per mount ── */
React.useEffect(() => {
if (!window.LightweightCharts || !hostRef.current) {
setErr('chart engine failed to load');
return;
}
const LW = window.LightweightCharts;
const base = {
layout: { background: { type: 'solid', color: 'transparent' },
textColor: ST.ink2, fontFamily: 'JetBrains Mono, monospace', fontSize: 12 },
grid: { vertLines: { color: ST.grid }, horzLines: { color: ST.grid } },
rightPriceScale: { borderColor: ST.border,
scaleMargins: { top: 0.06, bottom: 0.18 } },
timeScale: { borderColor: ST.border, timeVisible: true, secondsVisible: false,
rightOffset: 4, barSpacing: 16, minBarSpacing: 4,
/* stability: no scrolling/zooming into empty void on either
side (the "drifts left, blank on the right" bug), and the
view stays put when the panel resizes (rail collapse) */
fixLeftEdge: true, fixRightEdge: true,
lockVisibleTimeRangeOnResize: true, rightBarStaysOnScroll: true },
crosshair: {
mode: LW.CrosshairMode.Normal,
/* time label stays neutral; the PRICE label (right axis) is amber so the
number you're hovering pops off the dark candles instead of blending
into the black/white background — lightweight-charts auto-picks dark
text on the amber fill (= yellow chip, black numbers) */
vertLine: { color: ST.ink3, width: 1, style: LW.LineStyle.Dashed, labelBackgroundColor: ST.panel },
horzLine: { color: ST.amber, width: 1, style: LW.LineStyle.Dashed, labelBackgroundColor: ST.amber },
},
autoSize: true,
};
const chart = LW.createChart(hostRef.current, base);
const candle = chart.addCandlestickSeries({
upColor: ST.green, downColor: ST.red,
wickUpColor: ST.green, wickDownColor: ST.red,
borderVisible: false,
});
const volume = chart.addHistogramSeries({
priceScaleId: 'vol', priceFormat: { type: 'volume' },
lastValueVisible: false, priceLineVisible: false,
});
chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.84, bottom: 0 } });
const mkLine = (color, width, style) => chart.addLineSeries({
color, lineWidth: width, lineStyle: style || 0,
priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false,
});
const ema21 = mkLine(ST.amber, 2);
const ema55 = mkLine(ST.blue, 2);
const vwap = mkLine(ST.ink2, 1, 2);
let rsiChart = null, rsiLine = null;
if (showRSI && rsiRef.current) {
rsiChart = LW.createChart(rsiRef.current, {
...base,
rightPriceScale: { borderColor: ST.border,
scaleMargins: { top: 0.12, bottom: 0.08 } },
timeScale: { ...base.timeScale, visible: false },
});
rsiLine = rsiChart.addLineSeries({
color: ST.amber, lineWidth: 1,
priceLineVisible: false, lastValueVisible: true,
priceFormat: { type: 'price', precision: 1, minMove: 0.1 },
});
rsiLine.createPriceLine({ price: 70, color: `${ST.red}80`, lineWidth: 1, lineStyle: LW.LineStyle.Dashed, title: '70' });
rsiLine.createPriceLine({ price: 30, color: `${ST.green}80`, lineWidth: 1, lineStyle: LW.LineStyle.Dashed, title: '30' });
/* keep the two panes locked together */
let guard = false;
const link = (a, b) => a.timeScale().subscribeVisibleLogicalRangeChange(r => {
if (guard || !r) return;
guard = true; b.timeScale().setVisibleLogicalRange(r); guard = false;
});
link(chart, rsiChart); link(rsiChart, chart);
}
/* Update the OHLC readout only when the hovered BAR changes — per-pixel
setState re-rendered the toolbar ~60×/s and made the whole card flicker */
let lastBarT = undefined;
chart.subscribeCrosshairMove(p => {
const d = p && p.seriesData ? p.seriesData.get(candle) : null;
const t = d ? p.time : null;
if (t === lastBarT) return;
lastBarT = t;
setOhlc(d ? { o: d.open, h: d.high, l: d.low, c: d.close, t } : null);
});
chRef.current = { LW, chart, candle, volume, ema21, ema55, vwap, rsiChart, rsiLine, priceLines: [] };
return () => { chart.remove(); if (rsiChart) rsiChart.remove(); chRef.current = {}; };
}, [showRSI]);
/* ── data load (tf / sym change + 30s refresh) ── */
const load = React.useCallback((quiet) => {
const R = chRef.current;
if (!R.chart) return;
const preset = ST_TF[tf] || ST_TF['15m'];
const limit = Math.min(1000, preset.bars + 120); // +warmup for EMA55
ST_fetch(`/market/candles?symbol=${sym}&interval=${preset.iv}&limit=${limit}`)
.then(d => {
const cs = (d.candles || []).map(c => ({
time: Math.floor(c.open_time / 1000),
o: c.open, h: c.high, l: c.low, c: c.close, v: c.volume,
}));
if (!cs.length) { setErr('no data yet — syncing from Bybit, retry in a few sec'); return; }
setErr(null);
const closes = cs.map(c => c.c);
const e21 = stEma(closes, 21), e55 = stEma(closes, 55), vw = stVwap(cs), rs = stRsi(closes);
const stay = quiet ? R.chart.timeScale().getVisibleLogicalRange() : null;
const pinned = !quiet || (R.chart.timeScale().scrollPosition() > -3);
const prec = stPrecision(closes[closes.length - 1]);
R.candle.applyOptions({ priceFormat: { type: 'price', ...prec } });
R.candle.setData(cs.map(c => ({ time: c.time, open: c.o, high: c.h, low: c.l, close: c.c })));
R.volume.setData(cs.map(c => ({ time: c.time, value: c.v, color: (c.c >= c.o ? ST.green : ST.red) + '4D' })));
R.ema21.setData(cs.map((c, i) => ({ time: c.time, value: e21[i] })));
R.ema55.setData(cs.map((c, i) => ({ time: c.time, value: e55[i] })));
R.vwap.setData(cs.map((c, i) => ({ time: c.time, value: vw[i] })));
if (R.rsiLine) R.rsiLine.setData(cs.map((c, i) => ({ time: c.time, value: rs[i] })));
if (!quiet) {
/* first paint: show exactly the preset's window — big readable candles */
R.chart.timeScale().setVisibleLogicalRange(
{ from: Math.max(0, cs.length - preset.bars), to: cs.length + 2 });
} else if (!pinned && stay) {
R.chart.timeScale().setVisibleLogicalRange(stay);
}
const last = cs[cs.length - 1];
setMeta({ last: last.c, up: last.c >= last.o,
e21: e21[e21.length - 1], e55: e55[e55.length - 1],
vw: vw[vw.length - 1], rsi: rs[rs.length - 1] });
})
.catch(e => setErr(String(e.message || e)));
}, [sym, tf]);
React.useEffect(() => { load(false); }, [load]);
React.useEffect(() => { const t = setInterval(() => load(true), 30000); return () => clearInterval(t); }, [load]);
/* ── premium watermark: faint pair name behind the candles ── */
React.useEffect(() => {
const R = chRef.current;
if (!R.chart) return;
R.chart.applyOptions({ watermark: {
visible: true, text: `${sym}/USDT`, color: 'rgba(255,255,255,0.045)',
fontSize: 46, fontFamily: 'Geist, sans-serif',
horzAlign: 'center', vertAlign: 'center',
}});
}, [sym]);
/* ── overlays: order lines + S/R zone lines ── */
React.useEffect(() => {
const R = chRef.current;
if (!R.candle) return;
R.priceLines.forEach(pl => { try { R.candle.removePriceLine(pl); } catch (e) {} });
R.priceLines = [];
const add = (price, color, title, style) => {
if (price == null || isNaN(price)) return;
R.priceLines.push(R.candle.createPriceLine({
price, color, lineWidth: 1,
lineStyle: style != null ? style : 2, title,
axisLabelVisible: true,
}));
};
orders.forEach(o => {
const col = o.verdict === 'GOOD' ? ST.green : o.verdict === 'AVOID' ? ST.red : ST.amber;
add(o.price, col, `BUY ${o.qty != null ? o.qty : ''}`);
});
supportZones.forEach(z => { add(z.lo, ST.green, 'SUP', 3); add(z.hi, ST.green, '', 3); });
resistanceZones.forEach(z => { add(z.lo, ST.red, '', 3); add(z.hi, ST.red, 'RES', 3); });
}, [orders, supportZones, resistanceZones, sym, tf]);
/* ── UI ── */
const TfBtn = t => (
);
const Leg = ({ color, label, value, dash }) => (
{label}{value}
);
return (
{/* toolbar */}
{Object.keys(ST_TF).map(TfBtn)}
{meta && }
{meta && }
{meta && }
{/* fixed-width right block — swapping last↔OHLC must never reflow the
toolbar. The readout sits in a constant-width, right-aligned slot so
hovering (which swaps the short "last" line for the wider O/H/L/C
line) can't change the wrap point and nudge the chart down. */}
{ohlc ? (
O {stFmtPrice(ohlc.o)}{' '}
H {stFmtPrice(ohlc.h)}{' '}
L {stFmtPrice(ohlc.l)}{' '}
C = ohlc.o ? ST.green : ST.red }}>{stFmtPrice(ohlc.c)}
) : meta ? (
last {stFmtPrice(meta.last)}
{showRSI && · RSI 70 ? ST.red : meta.rsi < 30 ? ST.green : ST.ink }}>{meta.rsi.toFixed(1)}}
) : null}