|
|
@@ -5,7 +5,6 @@
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<title>价格与价差监控 MUBARAK/USDT</title>
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
- <!-- 引入 chartjs-plugin-zoom 插件 -->
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
|
|
<style>
|
|
|
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
|
|
|
@@ -21,12 +20,12 @@
|
|
|
.timestamp { font-size: 0.9em; color: #666; text-align: right; margin-top: 15px; }
|
|
|
.chart-container {
|
|
|
position: relative;
|
|
|
- height: 45vh; /* Slightly increased height for better zoom interaction */
|
|
|
+ height: 45vh;
|
|
|
width: 90vw;
|
|
|
margin: auto;
|
|
|
- margin-bottom: 10px; /* Reduced bottom margin */
|
|
|
+ margin-bottom: 10px;
|
|
|
}
|
|
|
- .controls-container { /* Container for buttons */
|
|
|
+ .controls-container {
|
|
|
text-align: center;
|
|
|
margin-bottom: 20px;
|
|
|
margin-top: 5px;
|
|
|
@@ -48,7 +47,7 @@
|
|
|
background-color: #0056b3;
|
|
|
}
|
|
|
.pause-button-active {
|
|
|
- background-color: #ffc107; /* Yellow when active (paused) */
|
|
|
+ background-color: #ffc107;
|
|
|
color: #333;
|
|
|
}
|
|
|
.pause-button-active:hover {
|
|
|
@@ -57,6 +56,7 @@
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
+ <!-- HTML 结构与之前相同,此处省略 -->
|
|
|
<div class="container">
|
|
|
<h1>MUBARAK/USDT 价格监控</h1>
|
|
|
<div class="controls-container">
|
|
|
@@ -113,21 +113,44 @@
|
|
|
<script>
|
|
|
let priceChartInstance = null;
|
|
|
let diffChartInstance = null;
|
|
|
- // const MAX_CHART_POINTS = 60; // MAX_HISTORY_POINTS is defined in backend
|
|
|
|
|
|
let dataUpdateIntervalID = null;
|
|
|
let isPaused = false;
|
|
|
- const REFRESH_INTERVAL_MS = 5000; // 5 seconds
|
|
|
+ const REFRESH_INTERVAL_MS = 5000;
|
|
|
const pauseResumeButton = document.getElementById('pause-resume-button');
|
|
|
|
|
|
+ let isSyncingZoomPan = false; // 标志位,防止同步死循环
|
|
|
+
|
|
|
function formatPrice(priceStr) {
|
|
|
if (priceStr === null || priceStr === undefined || priceStr === "N/A") return "N/A";
|
|
|
const price = parseFloat(priceStr);
|
|
|
return isNaN(price) ? "N/A" : price.toFixed(6);
|
|
|
}
|
|
|
|
|
|
- function initializeChart(ctx, chartType, datasetsConfig, initialLabels, titleText) {
|
|
|
- return new Chart(ctx, {
|
|
|
+ // 函数:从源图表同步X轴到目标图表
|
|
|
+ function syncXAxes(sourceChart, targetChart) {
|
|
|
+ if (isSyncingZoomPan || !sourceChart || !targetChart) return; // 如果正在同步或图表未定义,则跳过
|
|
|
+
|
|
|
+ isSyncingZoomPan = true; // 设置标志位
|
|
|
+
|
|
|
+ const sourceXScale = sourceChart.scales.x;
|
|
|
+ const targetXScale = targetChart.scales.x;
|
|
|
+
|
|
|
+ if (sourceXScale && targetXScale) {
|
|
|
+ targetXScale.options.min = sourceXScale.min; // 同步X轴的最小值
|
|
|
+ targetXScale.options.max = sourceXScale.max; // 同步X轴的最大值
|
|
|
+ targetChart.update('none'); // 更新目标图表,'none' 表示不执行动画
|
|
|
+ }
|
|
|
+
|
|
|
+ // 短暂延迟后重置标志位,允许下一次同步
|
|
|
+ // 如果不加延迟,快速连续操作可能会有问题
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ isSyncingZoomPan = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function initializeChart(ctx, chartIdentifier, chartType, datasetsConfig, initialLabels, titleText) {
|
|
|
+ const chartInstance = new Chart(ctx, {
|
|
|
type: 'line',
|
|
|
data: {
|
|
|
labels: initialLabels,
|
|
|
@@ -137,11 +160,12 @@
|
|
|
responsive: true,
|
|
|
maintainAspectRatio: false,
|
|
|
animation: {
|
|
|
- duration: 150 // slightly faster animation
|
|
|
+ duration: 150
|
|
|
},
|
|
|
scales: {
|
|
|
x: {
|
|
|
- title: { display: true, text: '时间' }
|
|
|
+ title: { display: true, text: '时间' },
|
|
|
+ // 我们将通过回调函数同步 min/max,这里不设置
|
|
|
},
|
|
|
y: {
|
|
|
title: { display: true, text: chartType === 'price' ? '价格 (USDT)' : '价差 (%)' },
|
|
|
@@ -151,24 +175,31 @@
|
|
|
plugins: {
|
|
|
legend: { position: 'top' },
|
|
|
title: { display: true, text: titleText },
|
|
|
- zoom: { // Zoom plugin configuration
|
|
|
+ zoom: {
|
|
|
pan: {
|
|
|
enabled: true,
|
|
|
- mode: 'xy', // Allow panning in x and y directions
|
|
|
- threshold: 5, // Pixels before pan starts
|
|
|
+ mode: 'x', // 只在X轴上平移 (Y轴通常不需要同步)
|
|
|
+ threshold: 5,
|
|
|
+ onPanComplete({chart}) { // 平移完成回调
|
|
|
+ if (chartIdentifier === 'price' && diffChartInstance) {
|
|
|
+ syncXAxes(chart, diffChartInstance);
|
|
|
+ } else if (chartIdentifier === 'diff' && priceChartInstance) {
|
|
|
+ syncXAxes(chart, priceChartInstance);
|
|
|
+ }
|
|
|
+ }
|
|
|
},
|
|
|
zoom: {
|
|
|
- wheel: {
|
|
|
- enabled: true, // Enable zooming with mouse wheel
|
|
|
- },
|
|
|
- pinch: {
|
|
|
- enabled: true // Enable zooming with pinch gesture (touch devices)
|
|
|
- },
|
|
|
- drag: { // Enable drag-to-zoom (box select)
|
|
|
- enabled: true,
|
|
|
- backgroundColor: 'rgba(0,123,255,0.25)'
|
|
|
- },
|
|
|
- mode: 'xy', // Allow zooming in x and y directions
|
|
|
+ wheel: { enabled: true, speed: 0.1 }, // 调整滚轮速度
|
|
|
+ pinch: { enabled: true },
|
|
|
+ drag: { enabled: true, backgroundColor: 'rgba(0,123,255,0.25)'},
|
|
|
+ mode: 'x', // 只在X轴上缩放
|
|
|
+ onZoomComplete({chart}) { // 缩放完成回调
|
|
|
+ if (chartIdentifier === 'price' && diffChartInstance) {
|
|
|
+ syncXAxes(chart, diffChartInstance);
|
|
|
+ } else if (chartIdentifier === 'diff' && priceChartInstance) {
|
|
|
+ syncXAxes(chart, priceChartInstance);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
@@ -177,24 +208,53 @@
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
+ return chartInstance;
|
|
|
}
|
|
|
|
|
|
function updateChartData(chartInstance, newLabels, newDatasetsData) {
|
|
|
+ if (!chartInstance) return; // 确保图表实例存在
|
|
|
+
|
|
|
+ const currentXMin = chartInstance.scales.x.min;
|
|
|
+ const currentXMax = chartInstance.scales.x.max;
|
|
|
+ const isZoomed = (currentXMin !== undefined && currentXMax !== undefined &&
|
|
|
+ (currentXMin !== chartInstance.data.labels[0] || currentXMax !== chartInstance.data.labels[chartInstance.data.labels.length - 1]));
|
|
|
+
|
|
|
chartInstance.data.labels = newLabels;
|
|
|
newDatasetsData.forEach((datasetData, index) => {
|
|
|
chartInstance.data.datasets[index].data = datasetData;
|
|
|
});
|
|
|
+
|
|
|
+ // 如果图表之前是缩放状态,并且新的标签范围与旧的缩放范围不完全匹配,
|
|
|
+ // 尝试保持缩放,否则新的数据可能会导致缩放重置。
|
|
|
+ // 但这里简单处理:如果暂停,不更新 x 轴的 min/max,让用户控制。
|
|
|
+ // 如果正在刷新,则让图表根据新数据自动调整。
|
|
|
+ // 若要更精细控制 (如保持缩放比例滚动),会更复杂。
|
|
|
+ if (!isPaused) {
|
|
|
+ // 当数据刷新时,如果用户是手动缩放的,我们不应该重置它
|
|
|
+ // Chart.js 会在数据更新时自动调整范围,除非 min/max 固定
|
|
|
+ // 为了允许新数据扩展X轴,我们不在这里设置 min/max
|
|
|
+ // 除非我们想实现固定窗口滚动
|
|
|
+ } else {
|
|
|
+ // 如果暂停了,并且图表被用户缩放了,保持这个缩放
|
|
|
+ if(isZoomed) {
|
|
|
+ chartInstance.options.scales.x.min = currentXMin;
|
|
|
+ chartInstance.options.scales.x.max = currentXMax;
|
|
|
+ } else {
|
|
|
+ // 如果没缩放,允许图表自动调整(尽管暂停时数据不会变)
|
|
|
+ chartInstance.options.scales.x.min = undefined;
|
|
|
+ chartInstance.options.scales.x.max = undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
chartInstance.update('quiet');
|
|
|
}
|
|
|
|
|
|
function updateDisplayAndCharts() {
|
|
|
- // If paused by user, don't fetch new data unless it's an initial call or manual resume
|
|
|
- // This check is implicitly handled by not having setInterval running when paused.
|
|
|
-
|
|
|
fetch('/data')
|
|
|
.then(response => response.json())
|
|
|
.then(data => {
|
|
|
const current = data.current;
|
|
|
+ // ... (表格数据更新部分与之前相同,此处省略以保持简洁) ...
|
|
|
document.getElementById('oo-price').textContent = formatPrice(current.oo_price);
|
|
|
document.getElementById('gate-price').textContent = formatPrice(current.gate_price);
|
|
|
|
|
|
@@ -226,30 +286,42 @@
|
|
|
|
|
|
const history = data.history;
|
|
|
|
|
|
+ // --- 价格历史图表 ---
|
|
|
const priceCtx = document.getElementById('priceHistoryChart').getContext('2d');
|
|
|
const priceDatasets = [
|
|
|
{ label: 'OpenOcean', data: history.prices.oo, borderColor: 'rgb(75, 192, 192)', tension: 0.1, fill: false },
|
|
|
{ label: 'Gate.io', data: history.prices.gate, borderColor: 'rgb(255, 99, 132)', tension: 0.1, fill: false }
|
|
|
];
|
|
|
if (!priceChartInstance) {
|
|
|
- priceChartInstance = initializeChart(priceCtx, 'price', priceDatasets, history.prices.labels, '价格历史 (MUBARAK/USDT)');
|
|
|
+ priceChartInstance = initializeChart(priceCtx, 'price', 'price', priceDatasets, history.prices.labels, '价格历史 (MUBARAK/USDT)');
|
|
|
} else {
|
|
|
updateChartData(priceChartInstance, history.prices.labels, [history.prices.oo, history.prices.gate]);
|
|
|
}
|
|
|
|
|
|
+ // --- 价差历史图表 ---
|
|
|
const diffCtx = document.getElementById('diffHistoryChart').getContext('2d');
|
|
|
const diffDatasets = [{ label: '价差百分比 (OO vs Gate)', data: history.difference.values, borderColor: 'rgb(54, 162, 235)', tension: 0.1, fill: false }];
|
|
|
if (!diffChartInstance) {
|
|
|
- diffChartInstance = initializeChart(diffCtx, 'diff', diffDatasets, history.difference.labels, '价差百分比历史');
|
|
|
+ diffChartInstance = initializeChart(diffCtx, 'diff', 'diff', diffDatasets, history.difference.labels, '价差百分比历史');
|
|
|
} else {
|
|
|
updateChartData(diffChartInstance, history.difference.labels, [history.difference.values]);
|
|
|
}
|
|
|
+
|
|
|
+ // 初始加载或取消暂停时، 手动同步一次 (如果 X 轴不同步)
|
|
|
+ // 避免初始加载时两个图表的 x 轴范围不一样
|
|
|
+ if (priceChartInstance && diffChartInstance &&
|
|
|
+ (priceChartInstance.scales.x.min !== diffChartInstance.scales.x.min ||
|
|
|
+ priceChartInstance.scales.x.max !== diffChartInstance.scales.x.max) &&
|
|
|
+ (!isPaused || !dataUpdateIntervalID ) // 只有在首次加载或从暂停恢复时才这样做
|
|
|
+ ) {
|
|
|
+ // console.log("Initial or resume sync needed");
|
|
|
+ // syncXAxes(priceChartInstance, diffChartInstance); // 让价格图表作为主导
|
|
|
+ }
|
|
|
+
|
|
|
})
|
|
|
.catch(error => {
|
|
|
console.error('Error fetching data:', error);
|
|
|
- document.getElementById('oo-price').textContent = '错误';
|
|
|
- document.getElementById('gate-price').textContent = '错误';
|
|
|
- document.getElementById('diff-percentage').textContent = '无法获取数据';
|
|
|
+ // ... (错误处理与之前相同) ...
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -257,40 +329,62 @@
|
|
|
isPaused = !isPaused;
|
|
|
if (isPaused) {
|
|
|
clearInterval(dataUpdateIntervalID);
|
|
|
- dataUpdateIntervalID = null; // Clear the ID
|
|
|
+ dataUpdateIntervalID = null;
|
|
|
pauseResumeButton.textContent = '继续刷新';
|
|
|
pauseResumeButton.classList.add('pause-button-active');
|
|
|
console.log("Data refresh PAUSED");
|
|
|
} else {
|
|
|
pauseResumeButton.textContent = '暂停刷新';
|
|
|
pauseResumeButton.classList.remove('pause-button-active');
|
|
|
- updateDisplayAndCharts(); // Refresh immediately upon resuming
|
|
|
+ updateDisplayAndCharts(); // Refresh immediately
|
|
|
dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
|
|
|
console.log("Data refresh RESUMED");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ function resetAllZooms() {
|
|
|
+ if (priceChartInstance) {
|
|
|
+ priceChartInstance.resetZoom();
|
|
|
+ // 重置后,手动清除 options 里的 min/max,确保下次数据更新能自动调整
|
|
|
+ priceChartInstance.options.scales.x.min = undefined;
|
|
|
+ priceChartInstance.options.scales.x.max = undefined;
|
|
|
+ priceChartInstance.update('none'); // 立即应用
|
|
|
+ }
|
|
|
+ if (diffChartInstance) {
|
|
|
+ diffChartInstance.resetZoom();
|
|
|
+ diffChartInstance.options.scales.x.min = undefined;
|
|
|
+ diffChartInstance.options.scales.x.max = undefined;
|
|
|
+ diffChartInstance.update('none'); // 立即应用
|
|
|
+ }
|
|
|
+ // 重置后,如果两个图表X轴范围不同,需要再次同步
|
|
|
+ // 简单起见,下次 updateDisplayAndCharts 时会自动尝试同步(如果需要)
|
|
|
+ // 或者我们可以在 updateDisplayAndCharts 中,如果 isZoomed 为 false,则不设置 min/max
|
|
|
+ if (priceChartInstance && diffChartInstance) {
|
|
|
+ // 在这里,我们希望两个图表都显示完整的数据范围
|
|
|
+ // 所以清空min/max后,让下次数据更新(如果是暂停状态,手动触发一次)来重建
|
|
|
+ if(isPaused){ // 如果是暂停状态,主动更新一下以确保X轴重置并数据重绘
|
|
|
+ updateDisplayAndCharts();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// --- Event Listeners ---
|
|
|
pauseResumeButton.addEventListener('click', togglePauseResume);
|
|
|
|
|
|
document.getElementById('reset-price-zoom-button').addEventListener('click', () => {
|
|
|
- if (priceChartInstance) {
|
|
|
- priceChartInstance.resetZoom();
|
|
|
- }
|
|
|
+ resetAllZooms(); // 现在一个按钮重置所有,并尝试同步
|
|
|
});
|
|
|
|
|
|
document.getElementById('reset-diff-zoom-button').addEventListener('click', () => {
|
|
|
- if (diffChartInstance) {
|
|
|
- diffChartInstance.resetZoom();
|
|
|
- }
|
|
|
+ resetAllZooms(); // 现在一个按钮重置所有,并尝试同步
|
|
|
});
|
|
|
|
|
|
// Initial data load and start interval
|
|
|
updateDisplayAndCharts();
|
|
|
- if (!isPaused) { // Start interval only if not initially paused (though it's false by default)
|
|
|
+ if (!isPaused) {
|
|
|
dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
</body>
|
|
|
-</html>
|
|
|
+</html>
|