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;