mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-06 05:16:26 +08:00
719 lines
29 KiB
HTML
719 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>数据分析</title>
|
||
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
|
||
<script src="/lib/js/chart.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
|
||
<style>
|
||
:root {
|
||
/* Modern Palette */
|
||
--primary-color: #4361ee;
|
||
--secondary-color: #3f37c9;
|
||
--success-color: #4caf50;
|
||
--info-color: #00b4d8;
|
||
--warning-color: #f72585;
|
||
--danger-color: #f44336;
|
||
--bg-color: #f8f9fa;
|
||
--card-bg: #ffffff;
|
||
--text-primary: #2b2d42;
|
||
--text-secondary: #8d99ae;
|
||
--border-color: #edf2f4;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||
margin: 0;
|
||
padding: 24px 40px;
|
||
background-color: var(--bg-color);
|
||
color: var(--text-primary);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.container {
|
||
width: 100%;
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
background: #fff;
|
||
padding: 20px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||
flex-wrap: wrap;
|
||
gap: 15px;
|
||
}
|
||
|
||
.header-title {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.server-status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
background-color: #e8f0fe;
|
||
color: var(--primary-color);
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
margin-left: 15px;
|
||
}
|
||
|
||
.server-status-pill i {
|
||
margin-right: 6px;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
color: var(--text-primary);
|
||
font-weight: 700;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.date-input {
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
font-family: inherit;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-refresh {
|
||
padding: 8px 18px;
|
||
background-color: var(--primary-color);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 4px rgba(67, 97, 238, 0.3);
|
||
}
|
||
|
||
.btn-refresh:hover {
|
||
background-color: var(--secondary-color);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(67, 97, 238, 0.4);
|
||
}
|
||
|
||
.charts-container {
|
||
display: flex;
|
||
gap: 24px;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.card {
|
||
background: #fff;
|
||
padding: 24px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||
border: 1px solid var(--border-color);
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.card-header {
|
||
margin-bottom: 20px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding-bottom: 10px;
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.chart-box {
|
||
position: relative;
|
||
height: 400px;
|
||
width: 100%;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
gap: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.col-half {
|
||
flex: 1;
|
||
min-width: 450px;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 60px;
|
||
color: var(--text-secondary);
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.error {
|
||
color: var(--danger-color);
|
||
text-align: center;
|
||
padding: 20px;
|
||
background: #ffebee;
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<div class="header-title">
|
||
<h2><i class="fas fa-chart-line"></i> 数据分析</h2>
|
||
<div id="server-status" class="server-status-pill" style="display: none;">
|
||
<i class="fas fa-users"></i> <span id="player-count-display">--/--</span>
|
||
</div>
|
||
</div>
|
||
<div class="controls">
|
||
<label>开始: <input type="date" id="startDate" class="date-input"></label>
|
||
<label>结束: <input type="date" id="endDate" class="date-input"></label>
|
||
<button onclick="loadAnalytics()" class="btn-refresh"><i class="fas fa-search"></i> 查询</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="loading" class="loading"><i class="fas fa-spinner fa-spin"></i> 正在加载数据...</div>
|
||
<div id="error-msg" class="error" style="display: none;"></div>
|
||
|
||
<div id="analytics-content" class="charts-container" style="display: none;">
|
||
<!-- 详细 Tick Rate 图表 -->
|
||
<div class="card">
|
||
<div class="card-header"><i class="fas fa-microchip"></i> 服务器性能详细分析 (Tick Rate / 玩家数 / 局时)</div>
|
||
<div class="chart-box">
|
||
<canvas id="tickRateChart"></canvas>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 10px; font-size: 0.85rem; color: var(--text-secondary);">
|
||
<i class="fas fa-info-circle"></i> 提示:在图表区域使用滚轮缩放,按住鼠标左键拖动平移
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<!-- 玩家人数趋势 -->
|
||
<div class="col-half card">
|
||
<div class="card-header"><i class="fas fa-user-friends"></i> 历史玩家人数趋势</div>
|
||
<div class="chart-box">
|
||
<canvas id="playerChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 地图热度 -->
|
||
<div class="col-half card">
|
||
<div class="card-header"><i class="fas fa-map"></i> 地图游玩热度排行</div>
|
||
<div class="chart-box">
|
||
<canvas id="mapChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Login Stats -->
|
||
<div class="card">
|
||
<div class="card-header"><i class="fas fa-users-cog"></i> 子账户登录情况统计</div>
|
||
<div class="chart-box" style="height: 300px; margin-bottom: 20px;">
|
||
<canvas id="userLoginChart"></canvas>
|
||
</div>
|
||
<div style="overflow-x: auto;">
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
|
||
<th style="padding: 12px; text-align: left;">用户名</th>
|
||
<th style="padding: 12px; text-align: center;">活跃度 (登录次数)</th>
|
||
<th style="padding: 12px; text-align: right;">最近登录时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="userLoginTableBody">
|
||
<!-- Data -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Permission Check
|
||
(function() {
|
||
const required = 'analytics';
|
||
const permsStr = localStorage.getItem('permissions');
|
||
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
|
||
|
||
if (isSuper) return;
|
||
|
||
if (!permsStr) {
|
||
document.body.innerHTML = '<div style="text-align:center; margin-top:50px;">请先登录</div>';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const perms = JSON.parse(permsStr);
|
||
if (!perms.includes('all') && !perms.includes(required)) {
|
||
document.body.innerHTML = '<div style="text-align:center; margin-top:50px; color:red;">您没有权限访问此页面</div>';
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
})();
|
||
|
||
// ================= Analytics Logic =================
|
||
// Register DataLabels plugin
|
||
Chart.register(ChartDataLabels);
|
||
|
||
let chartInstancePlayer = null;
|
||
let chartInstanceMap = null;
|
||
let chartInstanceTick = null;
|
||
let chartInstanceUserLogin = null;
|
||
|
||
function renderUserStats(data) {
|
||
const tableData = data.tableData || [];
|
||
|
||
// Render Table (Sorted by Activity Desc)
|
||
const tbody = document.getElementById('userLoginTableBody');
|
||
tbody.innerHTML = '';
|
||
tableData.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.style.borderBottom = '1px solid #edf2f4';
|
||
tr.innerHTML = `
|
||
<td style="padding: 12px; color: var(--primary-color); font-weight: 500;">${item.username || item.Username}</td>
|
||
<td style="padding: 12px; text-align: center;">${item.count || item.Count}</td>
|
||
<td style="padding: 12px; text-align: right; color: var(--text-secondary);">${new Date(item.lastLogin || item.LastLogin).toLocaleString()}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
// Render Chart
|
||
const ctx = document.getElementById('userLoginChart').getContext('2d');
|
||
if (chartInstanceUserLogin) chartInstanceUserLogin.destroy();
|
||
|
||
chartInstanceUserLogin = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: tableData.map(d => d.username || d.Username),
|
||
datasets: [{
|
||
label: '登录次数',
|
||
data: tableData.map(d => d.count || d.Count),
|
||
backgroundColor: 'rgba(67, 97, 238, 0.6)',
|
||
borderColor: 'rgba(67, 97, 238, 1)',
|
||
borderWidth: 1,
|
||
borderRadius: 4,
|
||
barThickness: 30
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
title: { display: true, text: '活跃度排名' },
|
||
datalabels: { display: false } // Disable labels for this chart
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: { precision: 0 }
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Init Dates
|
||
function initDates() {
|
||
const end = new Date();
|
||
const start = new Date();
|
||
start.setDate(end.getDate() - 3); // Default 3 days
|
||
|
||
document.getElementById('endDate').valueAsDate = end;
|
||
document.getElementById('startDate').valueAsDate = start;
|
||
}
|
||
initDates();
|
||
|
||
// Fetch current server status (players)
|
||
async function updateServerHeader() {
|
||
try {
|
||
const res = await fetch('/api/server-info');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.serverInfo) {
|
||
const current = data.serverInfo.playerCount_I || '0';
|
||
const max = data.serverInfo.maxPlayers || 100;
|
||
document.getElementById('player-count-display').innerText = `${current}/${max}`;
|
||
document.getElementById('server-status').style.display = 'inline-flex';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to fetch server info", e);
|
||
}
|
||
}
|
||
|
||
async function loadAnalytics() {
|
||
updateServerHeader();
|
||
const loading = document.getElementById('loading');
|
||
const content = document.getElementById('analytics-content');
|
||
const errorMsg = document.getElementById('error-msg');
|
||
const startDate = document.getElementById('startDate').value;
|
||
const endDate = document.getElementById('endDate').value;
|
||
|
||
loading.style.display = 'block';
|
||
content.style.display = 'none';
|
||
errorMsg.style.display = 'none';
|
||
errorMsg.innerText = '';
|
||
|
||
const queryParams = `?start=${startDate}&end=${endDate}`;
|
||
|
||
try {
|
||
// 1. Fetch Tick Stats
|
||
const tickRes = await fetch('/api/analytics/tickrate' + queryParams);
|
||
let tickData = [];
|
||
if (tickRes.ok) {
|
||
tickData = await tickRes.json();
|
||
renderTickChart(tickData);
|
||
}
|
||
|
||
// 2. Fetch Player/Map Stats
|
||
try {
|
||
const playerRes = await fetch('/api/analytics/players' + queryParams);
|
||
if (playerRes.ok) {
|
||
const playerData = await playerRes.json();
|
||
const mapRes = await fetch('/api/analytics/maps' + queryParams);
|
||
if (mapRes.ok) {
|
||
const mapData = await mapRes.json();
|
||
renderCharts(playerData, mapData);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn("Existing analytics endpoints failed or missing:", e);
|
||
}
|
||
|
||
// 3. Fetch User Login Stats
|
||
try {
|
||
const userStatsRes = await fetch('/api/analytics/user-stats');
|
||
if (userStatsRes.ok) {
|
||
const userStatsData = await userStatsRes.json();
|
||
renderUserStats(userStatsData);
|
||
}
|
||
} catch (e) {
|
||
console.warn("User stats fetch failed:", e);
|
||
}
|
||
|
||
loading.style.display = 'none';
|
||
content.style.display = 'flex';
|
||
} catch (err) {
|
||
console.error("Failed to load analytics:", err);
|
||
loading.style.display = 'none';
|
||
errorMsg.style.display = 'block';
|
||
errorMsg.innerText = '加载数据失败: ' + err.message;
|
||
}
|
||
}
|
||
|
||
const zoomOptions = {
|
||
pan: {
|
||
enabled: true,
|
||
mode: 'x'
|
||
},
|
||
zoom: {
|
||
wheel: {
|
||
enabled: true,
|
||
},
|
||
pinch: {
|
||
enabled: true
|
||
},
|
||
mode: 'x',
|
||
}
|
||
};
|
||
|
||
function renderTickChart(data) {
|
||
const ctx = document.getElementById('tickRateChart').getContext('2d');
|
||
if (chartInstanceTick) chartInstanceTick.destroy();
|
||
|
||
// Helper to get property regardless of case (camelCase or PascalCase)
|
||
const getVal = (obj, key) => {
|
||
if (!obj) return null;
|
||
if (obj[key] !== undefined) return obj[key];
|
||
const pascal = key.charAt(0).toUpperCase() + key.slice(1);
|
||
return obj[pascal];
|
||
};
|
||
|
||
chartInstanceTick = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: data.map(d => new Date(getVal(d, 'logTime')).toLocaleString()),
|
||
datasets: [
|
||
{
|
||
label: 'Server Tick Rate',
|
||
data: data.map(d => parseFloat(getVal(d, 'tickRate'))),
|
||
borderColor: '#4caf50',
|
||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||
yAxisID: 'y',
|
||
tension: 0.1,
|
||
pointRadius: 0,
|
||
borderWidth: 1.5
|
||
},
|
||
{
|
||
label: '在线玩家数',
|
||
data: data.map(d => getVal(d, 'playerCount')),
|
||
borderColor: '#ff9800',
|
||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||
yAxisID: 'y1',
|
||
tension: 0.1,
|
||
pointRadius: 0,
|
||
borderWidth: 1.5
|
||
},
|
||
{
|
||
label: '游戏开局时间 (分钟)',
|
||
data: data.map(d => {
|
||
const val = getVal(d, 'matchTime');
|
||
return val ? (parseFloat(val) / 60).toFixed(1) : 0;
|
||
}),
|
||
borderColor: '#2196f3',
|
||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||
yAxisID: 'y1',
|
||
tension: 0.1,
|
||
pointRadius: 0,
|
||
borderWidth: 1.5,
|
||
borderDash: [5, 5]
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { position: 'top' },
|
||
datalabels: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(context) {
|
||
return context.dataset.label + ': ' + context.parsed.y;
|
||
},
|
||
afterBody: function(context) {
|
||
if (context.length > 0) {
|
||
const index = context[0].dataIndex;
|
||
const item = data[index];
|
||
const mapName = getVal(item, 'mapName');
|
||
if (mapName) {
|
||
return '地图: ' + mapName;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
}
|
||
},
|
||
zoom: zoomOptions
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: true,
|
||
ticks: {
|
||
maxRotation: 0,
|
||
autoSkip: true,
|
||
maxTicksLimit: 12, // 限制X轴标签数量,避免拥挤
|
||
callback: function(val, index) {
|
||
// val is index
|
||
if (typeof val === 'number' && data[val]) {
|
||
const date = new Date(getVal(data[val], 'logTime'));
|
||
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
||
|
||
// Show date if it's the first point or if we are near a day change
|
||
// Since autoSkip is on, we can't rely on index-1 for visual boundary,
|
||
// but we can just show the date if it's the first label shown or looks important.
|
||
// Let's simply return Date + Time for clarity in this view
|
||
return (date.getMonth()+1) + '-' + date.getDate() + ' ' + timeStr;
|
||
}
|
||
return this.getLabelForValue(val);
|
||
}
|
||
},
|
||
grid: {
|
||
color: function(context) {
|
||
// Highlight day boundaries with a red line
|
||
if (context.tick && typeof context.tick.value === 'number') {
|
||
const index = context.tick.value;
|
||
if (index > 0 && data[index] && data[index-1]) {
|
||
const curr = new Date(getVal(data[index], 'logTime'));
|
||
const prev = new Date(getVal(data[index-1], 'logTime'));
|
||
if (curr.getDate() !== prev.getDate()) {
|
||
return 'rgba(255, 99, 132, 0.8)'; // Red line for day change
|
||
}
|
||
}
|
||
}
|
||
return '#edf2f4';
|
||
},
|
||
lineWidth: function(context) {
|
||
if (context.tick && typeof context.tick.value === 'number') {
|
||
const index = context.tick.value;
|
||
if (index > 0 && data[index] && data[index-1]) {
|
||
const curr = new Date(getVal(data[index], 'logTime'));
|
||
const prev = new Date(getVal(data[index-1], 'logTime'));
|
||
if (curr.getDate() !== prev.getDate()) {
|
||
return 2;
|
||
}
|
||
}
|
||
}
|
||
return 1;
|
||
}
|
||
}
|
||
},
|
||
y: {
|
||
type: 'linear',
|
||
display: true,
|
||
position: 'left',
|
||
title: { display: true, text: 'Tick Rate' },
|
||
min: 0,
|
||
},
|
||
y1: {
|
||
type: 'linear',
|
||
display: true,
|
||
position: 'right',
|
||
title: { display: true, text: '玩家数 / 时长(分)' },
|
||
grid: { drawOnChartArea: false },
|
||
min: 0,
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderCharts(playerData, mapData) {
|
||
const ctxPlayer = document.getElementById('playerChart').getContext('2d');
|
||
const ctxMap = document.getElementById('mapChart').getContext('2d');
|
||
|
||
if (chartInstancePlayer) chartInstancePlayer.destroy();
|
||
if (chartInstanceMap) chartInstanceMap.destroy();
|
||
|
||
// 1. Player Count Chart
|
||
chartInstancePlayer = new Chart(ctxPlayer, {
|
||
type: 'line',
|
||
data: {
|
||
labels: playerData.map(d => new Date(d.timestamp).toLocaleString()),
|
||
datasets: [{
|
||
label: '在线玩家人数',
|
||
data: playerData.map(d => d.playerCount),
|
||
borderColor: '#007bff',
|
||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||
fill: true,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { display: false },
|
||
zoom: zoomOptions,
|
||
datalabels: {
|
||
align: 'top',
|
||
anchor: 'end',
|
||
display: function(context) {
|
||
return context.dataIndex % 5 === 0;
|
||
},
|
||
color: '#007bff',
|
||
font: { weight: 'bold' }
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
title: function(context) {
|
||
if (context.length > 0) {
|
||
const index = context[0].dataIndex;
|
||
const d = playerData[index];
|
||
return new Date(d.timestamp).toLocaleString();
|
||
}
|
||
return '';
|
||
},
|
||
afterBody: function(context) {
|
||
if (context.length > 0) {
|
||
const index = context[0].dataIndex;
|
||
const item = playerData[index];
|
||
if (item && item.mapName) {
|
||
return '地图: ' + item.mapName;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: true,
|
||
ticks: {
|
||
maxTicksLimit: 12,
|
||
callback: function(val, index) {
|
||
// val is index
|
||
const d = playerData[val];
|
||
if (d) {
|
||
const date = new Date(d.timestamp);
|
||
return (date.getMonth() + 1) + '-' + date.getDate() + ' ' +
|
||
date.getHours().toString().padStart(2, '0') + ':' +
|
||
date.getMinutes().toString().padStart(2, '0');
|
||
}
|
||
return this.getLabelForValue(val);
|
||
}
|
||
}
|
||
},
|
||
y: { beginAtZero: true, max: 150 }
|
||
}
|
||
}
|
||
});
|
||
|
||
// 2. Map Frequency Chart
|
||
chartInstanceMap = new Chart(ctxMap, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: mapData.map(d => d.mapName),
|
||
datasets: [{
|
||
label: '时长(10min)',
|
||
data: mapData.map(d => d.count),
|
||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
indexAxis: 'y',
|
||
plugins: {
|
||
legend: { display: false },
|
||
datalabels: {
|
||
anchor: 'end',
|
||
align: 'end',
|
||
color: '#333',
|
||
font: { weight: 'bold' },
|
||
formatter: function(value) { return value; }
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
grace: '10%' // Add some space for the label
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Call once on init
|
||
setTimeout(loadAnalytics, 500);
|
||
</script>
|
||
</body>
|
||
</html> |