行情区图表:成交量、十字线 OHLCV、可视高低点、日线满 500 根

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 11:12:10 +08:00
parent ba681c7a58
commit 113d8c1669
7 changed files with 223 additions and 84 deletions
+8
View File
@@ -1988,6 +1988,14 @@ body.login-page {
border: 1px solid var(--border-soft);
font-size: 0.78rem;
min-width: 200px;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease;
}
.market-ohlcv-overlay.is-active {
opacity: 1;
visibility: visible;
}
.market-ohlcv-title {
+181 -66
View File
@@ -1,8 +1,7 @@
/**
* 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)
* 中控行情区:K 线 + 底部成交量;十字线时显示 OHLCV;可视区间高低点
*/
(function () {
const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"];
const chartHost = document.getElementById("market-chart");
if (!chartHost) return;
@@ -12,6 +11,7 @@
const elRefresh = document.getElementById("market-refresh");
const elStatus = document.getElementById("market-status");
const elUpdated = document.getElementById("market-updated");
const elOverlay = document.querySelector(".market-ohlcv-overlay");
const elO = document.getElementById("mkt-o");
const elH = document.getElementById("mkt-h");
const elL = document.getElementById("mkt-l");
@@ -22,9 +22,11 @@
let chart = null;
let candleSeries = null;
let volumeSeries = null;
let priceTick = null;
let rangeMarkers = [];
let lastCandles = [];
let candleByTime = {};
let chartMeta = null;
let loadToken = 0;
let marketInited = false;
@@ -38,6 +40,33 @@
return n.toFixed(2);
}
function fmtPrice(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n === 0) return "0";
const tick = priceTick;
if (tick && tick > 0) {
let decimals = tick >= 1 ? 0 : Math.max(0, Math.min(12, Math.round(-Math.log10(tick))));
let text = n.toFixed(decimals);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
let text = n.toFixed(d);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
function setOverlayVisible(on) {
if (!elOverlay) return;
elOverlay.classList.toggle("is-active", !!on);
}
function paintOhlcv(bar) {
if (!bar) {
["o", "h", "l", "c", "v"].forEach(function (k) {
@@ -46,15 +75,43 @@
});
return;
}
if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-";
if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-";
if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-";
if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-";
if (elO) elO.textContent = fmtPrice(bar.open);
if (elH) elH.textContent = fmtPrice(bar.high);
if (elL) elL.textContent = fmtPrice(bar.low);
if (elC) elC.textContent = fmtPrice(bar.close);
if (elV) elV.textContent = fmtVol(bar.volume);
}
function hideOhlcvOverlay() {
setOverlayVisible(false);
paintOhlcv(null);
}
function indexCandles(candles) {
candleByTime = {};
(candles || []).forEach(function (c) {
if (c && c.time != null) candleByTime[c.time] = c;
});
}
function candleAtTime(t) {
if (t == null) return null;
return candleByTime[t] || null;
}
function buildVolumeData(candles) {
return (candles || []).map(function (c) {
const up = Number(c.close) >= Number(c.open);
return {
time: c.time,
value: Number(c.volume) || 0,
color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)",
};
});
}
function ensureChart() {
if (chart && candleSeries) return true;
if (chart && candleSeries && volumeSeries) return true;
if (!window.LightweightCharts) {
if (elStatus) {
elStatus.className = "market-status err";
@@ -64,40 +121,77 @@
}
chart = LightweightCharts.createChart(chartHost, {
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
grid: { vertLines: { color: "#1a2838" }, horzLines: { color: "#1a2838" } },
grid: {
vertLines: { visible: false },
horzLines: { visible: false },
},
rightPriceScale: { borderColor: "#2a4058" },
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 },
crosshair: {
mode: LightweightCharts.CrosshairMode
? LightweightCharts.CrosshairMode.Normal
: 0,
},
});
const opts = {
const candleOpts = {
upColor: "#00ff9d",
downColor: "#ff4d6d",
borderVisible: false,
wickUpColor: "#00ff9d",
wickDownColor: "#ff4d6d",
};
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
candleSeries = chart.addCandlestickSeries(candleOpts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts);
}
if (!candleSeries) return false;
const volOpts = {
priceFormat: { type: "volume" },
priceScaleId: "volume",
lastValueVisible: false,
};
if (typeof chart.addHistogramSeries === "function") {
volumeSeries = chart.addHistogramSeries(volOpts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.HistogramSeries
) {
volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts);
}
if (!volumeSeries) return false;
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: 0.28 },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: 0.78, bottom: 0 },
});
chart.subscribeCrosshairMove(function (param) {
if (!param || !param.time || !param.seriesData) return;
const d = param.seriesData.get(candleSeries);
if (!d) return;
paintOhlcv({
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
});
if (!param || param.time == null) {
hideOhlcvOverlay();
return;
}
const bar = candleAtTime(param.time);
if (!bar) {
hideOhlcvOverlay();
return;
}
setOverlayVisible(true);
paintOhlcv(bar);
});
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
updateVisibleRangeMarkers();
});
window.addEventListener("resize", function () {
@@ -105,6 +199,7 @@
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
});
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
hideOhlcvOverlay();
return true;
}
@@ -117,35 +212,47 @@
rangeMarkers = [];
}
function addRangeMarkers(data) {
function updateVisibleRangeMarkers() {
clearMarkers();
if (!candleSeries || !data) return;
const hi = data.range_high;
const lo = data.range_low;
if (hi && hi.price != null) {
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(hi.price),
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "区间高",
})
);
}
if (lo && lo.price != null) {
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(lo.price),
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "区间低",
})
);
if (!candleSeries || !chart || !lastCandles.length) return;
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) return;
const from = Math.max(0, Math.floor(range.from));
const to = Math.min(lastCandles.length - 1, Math.ceil(range.to));
if (to < from) return;
let hi = null;
let lo = null;
for (let i = from; i <= to; i++) {
const c = lastCandles[i];
if (!c) continue;
if (!hi || c.high > hi.high) hi = c;
if (!lo || c.low < lo.low) lo = c;
}
if (!hi || !lo) return;
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(hi.high),
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "可视高",
})
);
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(lo.low),
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "可视低",
})
);
}
function readQuery() {
@@ -158,6 +265,11 @@
if (tf && elTf) elTf.value = tf;
}
function applyDefaults() {
if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT";
if (elTf && !elTf.value) elTf.value = "1d";
}
async function loadMeta() {
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
chartMeta = await r.json();
@@ -170,13 +282,14 @@
elExchange.appendChild(opt);
});
readQuery();
applyDefaults();
}
async function loadChart(force) {
if (!ensureChart()) return;
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "5m";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) {
if (elStatus) {
elStatus.className = "market-status err";
@@ -189,6 +302,7 @@
elStatus.className = "market-status";
elStatus.textContent = "加载中…";
}
hideOhlcvOverlay();
if (elSymLabel) elSymLabel.textContent = sym;
if (elTfLabel) elTfLabel.textContent = tf;
@@ -212,23 +326,19 @@
priceTick = data.price_tick;
lastCandles = data.candles;
candleSeries.setData(data.candles);
indexCandles(lastCandles);
candleSeries.setData(lastCandles);
volumeSeries.setData(buildVolumeData(lastCandles));
chart.timeScale().fitContent();
addRangeMarkers(data);
const ohlcv = data.ohlcv || {};
paintOhlcv({
open: ohlcv.open,
high: ohlcv.high,
low: ohlcv.low,
close: ohlcv.close,
volume: ohlcv.volume,
});
updateVisibleRangeMarkers();
const limit = data.limit || lastCandles.length;
let hint =
"已加载 " +
data.candles.length +
" 根( " +
" 根(目标 " +
limit +
")· 库 " +
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
@@ -274,9 +384,11 @@
});
}
const btnLoad = document.getElementById("market-load");
if (btnLoad) btnLoad.addEventListener("click", function () {
loadChart(false);
});
if (btnLoad) {
btnLoad.addEventListener("click", function () {
loadChart(false);
});
}
}
window.hubMarketChart = {
@@ -293,7 +405,10 @@
},
};
if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) {
if (
document.getElementById("page-market") &&
!document.getElementById("page-market").classList.contains("hidden")
) {
window.hubMarketChart.init();
}
})();
+7 -7
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-market" />
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-market2" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -76,17 +76,17 @@
</label>
<label class="market-field">
<span>币种</span>
<input id="market-symbol" type="text" placeholder="TON/USDT" autocomplete="off" />
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
</label>
<label class="market-field">
<span>周期</span>
<select id="market-timeframe">
<option value="1m">1m</option>
<option value="5m" selected>5m</option>
<option value="5m">5m</option>
<option value="15m">15m</option>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="1d">1d</option>
<option value="1d" selected>1d</option>
<option value="1w">1w</option>
</select>
</label>
@@ -100,7 +100,7 @@
<div class="market-ohlcv-overlay" aria-label="K线详情">
<div class="market-ohlcv-title">
<span id="mkt-symbol-label"></span>
<span id="mkt-tf-label">5m</span>
<span id="mkt-tf-label">1d</span>
</div>
<div class="market-ohlcv-grid">
<div><span class="k"></span><span id="mkt-o"></span></div>
@@ -179,7 +179,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260528-hub-market"></script>
<script src="/assets/app.js?v=20260528-hub-market"></script>
<script src="/assets/chart.js?v=20260528-hub-market2"></script>
<script src="/assets/app.js?v=20260528-hub-market2"></script>
</body>
</html>