226 lines
9.4 KiB
JavaScript
226 lines
9.4 KiB
JavaScript
// Filename: web/static/js/status-grid.js (V6.0 - 健壮初始化最终版)
|
|
// export default function initializeStatusGrid() {
|
|
class StatusGrid {
|
|
constructor(canvasId) {
|
|
this.canvas = document.getElementById(canvasId);
|
|
if (!this.canvas) { return; }
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
this.config = {
|
|
cols: 100, rows: 4, gap: 2, cornerRadius: 2,
|
|
colors: {
|
|
'ACTIVE': '#22C55E', 'ACTIVE_BLINK': '#A7F3D0',
|
|
'PENDING': '#9CA3AF', 'COOLDOWN': '#EAB308',
|
|
'DISABLED': '#F97316', 'BANNED': '#EF4444',
|
|
'EMPTY': '#F3F4F6',
|
|
},
|
|
blinkInterval: 100, blinksPerInterval: 2, blinkDuration: 200,
|
|
statusOrder: ['ACTIVE', 'COOLDOWN', 'DISABLED', 'BANNED', 'PENDING']
|
|
};
|
|
this.state = {
|
|
squares: [], squareSize: 0,
|
|
devicePixelRatio: window.devicePixelRatio || 1,
|
|
animationFrameId: null, blinkIntervalId: null,
|
|
blinkingSquares: new Set()
|
|
};
|
|
|
|
this.debouncedResize = this.debounce(this.resize.bind(this), 250);
|
|
}
|
|
init() {
|
|
this.setupCanvas();
|
|
// 初始化时,绘制一个完全为空的网格
|
|
this.state.squares = Array(this.config.rows * this.config.cols).fill({ status: 'EMPTY' });
|
|
this.drawFullGrid();
|
|
window.addEventListener('resize', this.debouncedResize);
|
|
}
|
|
|
|
/**
|
|
* [灵魂重塑] 这是被彻底重写的核心函数
|
|
* @param {object} keyStatusCounts - 例如 { "BANNED": 1, "DISABLED": 4, ... }
|
|
*/
|
|
updateData(keyStatusCounts) {
|
|
if (!keyStatusCounts) return;
|
|
|
|
this.destroyAnimation();
|
|
this.state.squares = [];
|
|
this._activeIndices = null;
|
|
this.state.blinkingSquares.clear();
|
|
const totalKeys = Object.values(keyStatusCounts).reduce((s, c) => s + c, 0);
|
|
const totalSquares = this.config.rows * this.config.cols;
|
|
// 如果没有密钥,则显示为空白的网格
|
|
if (totalKeys === 0) {
|
|
this.init();
|
|
return;
|
|
}
|
|
let statusMap = [];
|
|
let calculatedSquares = 0;
|
|
// 1. 严格按照比例,计算每个状态应该占据多少个“像素”
|
|
for (const status of this.config.statusOrder) {
|
|
const count = keyStatusCounts[status] || 0;
|
|
if (count > 0) {
|
|
const proportion = count / totalKeys;
|
|
const squaresForStatus = Math.floor(proportion * totalSquares);
|
|
for (let i = 0; i < squaresForStatus; i++) {
|
|
statusMap.push(status);
|
|
}
|
|
calculatedSquares += squaresForStatus;
|
|
}
|
|
}
|
|
|
|
// 2. [关键] 修正四舍五入的误差,将剩余的方块填满
|
|
const remainingSquares = totalSquares - calculatedSquares;
|
|
if (remainingSquares > 0) {
|
|
// 将剩余方块,全部分配给数量最多的那个状态,以使其最不失真
|
|
let largestStatus = this.config.statusOrder[0]; // 默认给第一个
|
|
let maxCount = -1;
|
|
for (const status in keyStatusCounts) {
|
|
if (keyStatusCounts[status] > maxCount) {
|
|
maxCount = keyStatusCounts[status];
|
|
largestStatus = status;
|
|
}
|
|
}
|
|
for (let i = 0; i < remainingSquares; i++) {
|
|
statusMap.push(largestStatus);
|
|
}
|
|
}
|
|
|
|
// 3. 将最终的、按比例填充的地图,转化为内部的 square 对象
|
|
this.state.squares = statusMap.map(status => ({
|
|
status: status,
|
|
isBlinking: false,
|
|
blinkUntil: 0
|
|
}));
|
|
|
|
// 4. [渲染修正] 直接、完整地重绘整个网格,然后启动动画
|
|
this.drawFullGrid();
|
|
this.startAnimationSystem();
|
|
}
|
|
// [渲染修正] 一个简单、直接、一次性绘制所有方块的函数
|
|
drawFullGrid() {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
this.ctx.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
// offsetX 和 offsetY 是在 setupCanvas 中计算的
|
|
const offsetX = this.state.offsetX;
|
|
const offsetY = this.state.offsetY;
|
|
this.state.squares.forEach((square, i) => {
|
|
const c = i % this.config.cols;
|
|
const r = Math.floor(i / this.config.cols);
|
|
|
|
const x = offsetX + c * (this.state.squareSize + this.config.gap);
|
|
const y = offsetY + r * (this.state.squareSize + this.config.gap);
|
|
const color = this.config.colors[square.status];
|
|
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
|
});
|
|
}
|
|
|
|
startAnimationSystem() {
|
|
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
|
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
|
|
|
this.state.blinkIntervalId = setInterval(() => {
|
|
const activeSquareIndices = this.getActiveSquareIndices();
|
|
if (activeSquareIndices.length === 0) return;
|
|
|
|
for(let i = 0; i < this.config.blinksPerInterval; i++) {
|
|
const randomIndex = activeSquareIndices[Math.floor(Math.random() * activeSquareIndices.length)];
|
|
const square = this.state.squares[randomIndex];
|
|
|
|
if (square && !square.isBlinking) {
|
|
square.isBlinking = true;
|
|
square.blinkUntil = performance.now() + this.config.blinkDuration;
|
|
this.state.blinkingSquares.add(randomIndex);
|
|
}
|
|
}
|
|
}, this.config.blinkInterval);
|
|
|
|
const animationLoop = (timestamp) => {
|
|
if (this.state.blinkingSquares.size > 0) {
|
|
this.state.blinkingSquares.forEach(index => {
|
|
const square = this.state.squares[index];
|
|
if(!square) { // 防御性检查
|
|
this.state.blinkingSquares.delete(index);
|
|
return;
|
|
}
|
|
const c = index % this.config.cols;
|
|
const r = Math.floor(index / this.config.cols);
|
|
|
|
this.drawSquare(square, c, r);
|
|
|
|
if (timestamp > square.blinkUntil) {
|
|
square.isBlinking = false;
|
|
this.state.blinkingSquares.delete(index);
|
|
this.drawSquare(square, c, r);
|
|
}
|
|
});
|
|
}
|
|
this.state.animationFrameId = requestAnimationFrame(animationLoop);
|
|
};
|
|
animationLoop();
|
|
}
|
|
|
|
setupCanvas() {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
this.canvas.width = rect.width * this.state.devicePixelRatio;
|
|
this.canvas.height = rect.height * this.state.devicePixelRatio;
|
|
this.ctx.scale(this.state.devicePixelRatio, this.state.devicePixelRatio);
|
|
const calculatedWidth = (rect.width - (this.config.cols - 1) * this.config.gap) / this.config.cols;
|
|
const calculatedHeight = (rect.height - (this.config.rows - 1) * this.config.gap) / this.config.rows;
|
|
this.state.squareSize = Math.max(1, Math.floor(Math.min(calculatedWidth, calculatedHeight)));
|
|
const totalGridWidth = this.config.cols * this.state.squareSize + (this.config.cols - 1) * this.config.gap;
|
|
const totalGridHeight = this.config.rows * this.state.squareSize + (this.config.rows - 1) * this.config.gap;
|
|
this.state.offsetX = Math.floor((rect.width - totalGridWidth) / 2);
|
|
this.state.offsetY = Math.floor((rect.height - totalGridHeight) / 2);
|
|
}
|
|
|
|
getActiveSquareIndices() {
|
|
if (!this._activeIndices) {
|
|
this._activeIndices = [];
|
|
this.state.squares.forEach((s, i) => {
|
|
if (s.status === 'ACTIVE') this._activeIndices.push(i);
|
|
});
|
|
}
|
|
return this._activeIndices;
|
|
}
|
|
|
|
drawRoundedRect(x, y, size, radius, color) {
|
|
this.ctx.fillStyle = color;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(x + radius, y);
|
|
this.ctx.arcTo(x + size, y, x + size, y + size, radius);
|
|
this.ctx.arcTo(x + size, y + size, x, y + size, radius);
|
|
this.ctx.arcTo(x, y + size, x, y, radius);
|
|
this.ctx.arcTo(x, y, x + size, y, radius);
|
|
this.ctx.closePath();
|
|
this.ctx.fill();
|
|
}
|
|
|
|
drawSquare(square, c, r) {
|
|
const x = this.state.offsetX + c * (this.state.squareSize + this.config.gap);
|
|
const y = this.state.offsetY + r * (this.state.squareSize + this.config.gap);
|
|
const color = square.isBlinking ? this.config.colors.ACTIVE_BLINK : this.config.colors[square.status];
|
|
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
|
}
|
|
|
|
destroyAnimation() {
|
|
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
|
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
|
this.state.animationFrameId = null;
|
|
this.state.blinkIntervalId = null;
|
|
}
|
|
|
|
resize() {
|
|
this.destroyAnimation();
|
|
this.init(); // 重新绘制空网格
|
|
// 这里依赖dashboard.js在resize后重新获取并传入数据
|
|
}
|
|
|
|
debounce(func, delay) {
|
|
let timeout;
|
|
return (...args) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func.apply(this, args), delay);
|
|
};
|
|
}
|
|
}
|