diff --git a/web/charts.js b/web/charts.js index 9e15c52..cd96c11 100644 --- a/web/charts.js +++ b/web/charts.js @@ -46,6 +46,9 @@ let lwcModalCandles = []; let lwcModalInterval = "1d"; let lwcModalPriceMeta = { tick_size: "0.01", price_precision: 2 }; let lwcOnVisibleRangeChange = null; +let lwcOnCrosshairMove = null; +let lwcOnChartClick = null; +let lwcPinnedCandleTime = null; function cacheKey(symbol, interval) { return `${symbol}:${interval}`; @@ -109,6 +112,142 @@ function formatPrice(price, precision) { return Number(price).toFixed(precision); } +function formatVolume(val) { + const v = Number(val); + if (!Number.isFinite(v)) return "—"; + if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`; + if (v >= 1e4) return `${(v / 1e4).toFixed(2)}万`; + return v.toFixed(2); +} + +function calcAmplitude(candle) { + const open = Number(candle.open); + const high = Number(candle.high); + const low = Number(candle.low); + if (!open) return "—"; + return `${(((high - low) / open) * 100).toFixed(2)}%`; +} + +function lwcTimeEquals(a, b) { + if (a == null || b == null) return false; + if (typeof a === "number" && typeof b === "number") return a === b; + if (typeof a === "object" && typeof b === "object") { + return a.year === b.year && a.month === b.month && a.day === b.day; + } + return false; +} + +function findCandleByLwcTime(time) { + if (time == null) return null; + for (const c of lwcModalCandles) { + if (lwcTimeEquals(toLwcTime(c.time, lwcModalInterval), time)) return c; + } + return null; +} + +function formatCandleTimeLabel(ms, interval) { + const d = new Date(ms); + if (interval === "1d" || interval === "1w") { + return d.toLocaleDateString("zh-CN"); + } + return d.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function renderOhlcPanel(candle, modeLabel) { + const panel = document.getElementById("chart-ohlc-panel"); + if (!panel) return; + if (!candle) { + panel.innerHTML = ''; + return; + } + + const precision = lwcModalPriceMeta.price_precision ?? 2; + const up = Number(candle.close) >= Number(candle.open); + const closeCls = up ? "up" : "down"; + const vol = formatVolume(candle.quote_volume || candle.volume); + const timeLabel = formatCandleTimeLabel(candle.time, lwcModalInterval); + + panel.innerHTML = ` +
+ ${modeLabel} + ${timeLabel} +
+
+ ${formatPrice(candle.open, precision)} + ${formatPrice(candle.high, precision)} + ${formatPrice(candle.low, precision)} + ${formatPrice(candle.close, precision)} + ${vol} + 振幅 ${calcAmplitude(candle)} +
`; +} + +function showLatestOhlcPanel() { + const last = lwcModalCandles[lwcModalCandles.length - 1]; + renderOhlcPanel(last, "最新"); +} + +function updateOhlcFromTime(time, modeLabel) { + const candle = findCandleByLwcTime(time); + if (candle) renderOhlcPanel(candle, modeLabel); +} + +function onChartAutoscale() { + if (!lwcChart) return; + lwcPinnedCandleTime = null; + lwcChart.timeScale().fitContent(); + lwcChart.priceScale("right").applyOptions({ autoScale: true }); + requestAnimationFrame(() => { + updateHighLowForVisibleWindow(); + showLatestOhlcPanel(); + }); +} + +function unbindChartInteractions() { + if (lwcChart && lwcOnCrosshairMove) { + lwcChart.unsubscribeCrosshairMove(lwcOnCrosshairMove); + } + if (lwcChart && lwcOnChartClick) { + lwcChart.unsubscribeClick(lwcOnChartClick); + } + lwcOnCrosshairMove = null; + lwcOnChartClick = null; +} + +function bindChartInteractions() { + if (!lwcChart) return; + unbindChartInteractions(); + + lwcOnCrosshairMove = (param) => { + if (lwcPinnedCandleTime != null) return; + if (param.time) { + updateOhlcFromTime(param.time, "当前"); + return; + } + showLatestOhlcPanel(); + }; + + lwcOnChartClick = (param) => { + if (!param.time) return; + if (lwcPinnedCandleTime != null && lwcTimeEquals(lwcPinnedCandleTime, param.time)) { + lwcPinnedCandleTime = null; + updateOhlcFromTime(param.time, "当前"); + return; + } + lwcPinnedCandleTime = param.time; + updateOhlcFromTime(param.time, "选中"); + }; + + lwcChart.subscribeCrosshairMove(lwcOnCrosshairMove); + lwcChart.subscribeClick(lwcOnChartClick); +} + function rememberPriceMeta(symbol, meta) { if (!meta?.tick_size) return null; const priceMeta = { @@ -410,8 +549,10 @@ async function loadMiniChart(box) { function destroyLwcChart() { unbindVisibleRangeHighLow(); + unbindChartInteractions(); clearHighLowAnnotations(); lwcModalCandles = []; + lwcPinnedCandleTime = null; if (lwcResizeObserver) { lwcResizeObserver.disconnect(); lwcResizeObserver = null; @@ -561,6 +702,7 @@ function ensureLwcChart(container) { lwcResizeObserver.observe(container); bindVisibleRangeHighLow(); + bindChartInteractions(); return lwcChart; } @@ -578,12 +720,16 @@ function renderLwcChart(candles, interval, priceMeta) { lwcModalCandles = candles; lwcModalInterval = interval; lwcModalPriceMeta = meta; + lwcPinnedCandleTime = null; const { ohlc, vol } = candlesToLwc(candles, interval); lwcCandleSeries.setData(ohlc); lwcVolumeSeries.setData(vol); lwcChart.timeScale().fitContent(); - requestAnimationFrame(() => updateHighLowForVisibleWindow()); + requestAnimationFrame(() => { + updateHighLowForVisibleWindow(); + showLatestOhlcPanel(); + }); } function updateIntervalTabs() { @@ -599,7 +745,7 @@ function updateModalMeta(candles, source, interval) { title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`; } if (hint) { - hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 最高/最低随可见窗口 · 滚轮缩放 · Esc 关闭`; + hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 十字线看当前 · 点击选中 · Esc 关闭`; } } @@ -674,6 +820,8 @@ function setupChartModal() {

+
+
`; @@ -694,6 +842,7 @@ function setupChartModal() { }); modal.querySelector(".chart-modal-close").onclick = closeChartModal; + document.getElementById("chart-autoscale-btn")?.addEventListener("click", onChartAutoscale); modal.addEventListener("click", (e) => { if (e.target === modal) closeChartModal(); }); diff --git a/web/style.css b/web/style.css index 2bd4986..3c9cbcd 100644 --- a/web/style.css +++ b/web/style.css @@ -501,12 +501,85 @@ button:hover { } .chart-modal-canvas-wrap { + position: relative; overflow: hidden; border-radius: 8px; border: 1px solid var(--border); background: #0d1118; } +.chart-ohlc-panel { + position: absolute; + top: 10px; + left: 12px; + z-index: 5; + pointer-events: none; + padding: 0.45rem 0.65rem; + border-radius: 6px; + background: rgba(13, 17, 24, 0.82); + border: 1px solid var(--border); + font-size: 0.82rem; + color: var(--text); + line-height: 1.45; + max-width: calc(100% - 120px); +} + +.chart-ohlc-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.2rem; + color: var(--muted); + font-size: 0.75rem; +} + +.chart-ohlc-mode { + color: var(--accent); + font-weight: 600; +} + +.chart-ohlc-row { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; +} + +.chart-ohlc-row b { + font-weight: 600; + color: var(--text); +} + +.chart-ohlc-row b.up { + color: #0ecb81; +} + +.chart-ohlc-row b.down { + color: #f6465d; +} + +.chart-ohlc-empty { + color: var(--muted); +} + +.chart-autoscale-btn { + position: absolute; + right: 58px; + bottom: 28px; + z-index: 5; + padding: 0.2rem 0.55rem; + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: rgba(13, 17, 24, 0.88); + color: var(--muted); + cursor: pointer; +} + +.chart-autoscale-btn:hover { + color: var(--text); + border-color: var(--accent); +} + .chart-modal-close { float: right; background: transparent;