Files
antigravity-claude-proxy/public/js/components/dashboard/charts.js

543 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dashboard Charts Module
* 职责:使用 Chart.js 渲染配额分布图和使用趋势图
*
* 调用时机:
* - dashboard 组件 init() 时初始化图表
* - 筛选器变化时更新图表数据
* - $store.data 更新时刷新图表
*
* 图表类型:
* 1. Quota Distribution饼图按模型家族或具体模型显示配额分布
* 2. Usage Trend折线图显示历史使用趋势
*
* 特殊处理:
* - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
* - 通过 debounce 优化频繁更新的性能
* - 响应式处理:移动端自动调整图表大小和标签显示
*
* @module DashboardCharts
*/
window.DashboardCharts = window.DashboardCharts || {};
// Helper to get CSS variable values (alias to window.utils.getThemeColor)
const getThemeColor = (name) => window.utils.getThemeColor(name);
// Color palette for different families and models
const FAMILY_COLORS = {
get claude() {
return getThemeColor("--color-neon-purple");
},
get gemini() {
return getThemeColor("--color-neon-green");
},
get other() {
return getThemeColor("--color-neon-cyan");
},
};
const MODEL_COLORS = Array.from({ length: 16 }, (_, i) =>
getThemeColor(`--color-chart-${i + 1}`)
);
// Export constants for filter module
window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS };
// Module-level lock to prevent concurrent chart updates (fixes race condition)
let _trendChartUpdateLock = false;
/**
* Convert hex color to rgba
* @param {string} hex - Hex color string
* @param {number} alpha - Alpha value (0-1)
* @returns {string} rgba color string
*/
window.DashboardCharts.hexToRgba = function (hex, alpha) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return `rgba(${parseInt(result[1], 16)}, ${parseInt(
result[2],
16
)}, ${parseInt(result[3], 16)}, ${alpha})`;
}
return hex;
};
/**
* Check if canvas is ready for Chart creation
* @param {HTMLCanvasElement} canvas - Canvas element
* @returns {boolean} True if canvas is ready
*/
function isCanvasReady(canvas) {
if (!canvas || !canvas.isConnected) return false;
if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false;
try {
const ctx = canvas.getContext("2d");
return !!ctx;
} catch (e) {
return false;
}
}
/**
* Create a Chart.js dataset with gradient fill
* @param {string} label - Dataset label
* @param {Array} data - Data points
* @param {string} color - Line color
* @param {HTMLCanvasElement} canvas - Canvas element
* @returns {object} Chart.js dataset configuration
*/
window.DashboardCharts.createDataset = function (label, data, color, canvas) {
let gradient;
try {
// Safely create gradient with fallback
if (canvas && canvas.getContext) {
const ctx = canvas.getContext("2d");
if (ctx && ctx.createLinearGradient) {
gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12));
gradient.addColorStop(
0.6,
window.DashboardCharts.hexToRgba(color, 0.05)
);
gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
}
}
} catch (e) {
console.warn("Failed to create gradient, using solid color fallback:", e);
gradient = null;
}
// Fallback to solid color if gradient creation failed
const backgroundColor =
gradient || window.DashboardCharts.hexToRgba(color, 0.08);
return {
label,
data,
borderColor: color,
backgroundColor: backgroundColor,
borderWidth: 2.5,
tension: 0.35,
fill: true,
pointRadius: 2.5,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: "rgba(9, 9, 11, 0.8)",
pointBorderWidth: 1.5,
};
};
/**
* Update quota distribution donut chart
* @param {object} component - Dashboard component instance
*/
window.DashboardCharts.updateCharts = function (component) {
// Safely destroy existing chart instance FIRST
if (component.charts.quotaDistribution) {
try {
component.charts.quotaDistribution.destroy();
} catch (e) {
console.error("Failed to destroy quota chart:", e);
}
component.charts.quotaDistribution = null;
}
const canvas = document.getElementById("quotaChart");
// Safety checks
if (!canvas) {
console.debug("quotaChart canvas not found");
return;
}
if (typeof Chart === "undefined") {
console.warn("Chart.js not loaded");
return;
}
if (!isCanvasReady(canvas)) {
console.debug("quotaChart canvas not ready, skipping update");
return;
}
// Use UNFILTERED data for global health chart
const rows = Alpine.store("data").getUnfilteredQuotaData();
if (!rows || rows.length === 0) return;
const healthByFamily = {};
let totalHealthSum = 0;
let totalModelCount = 0;
rows.forEach((row) => {
const family = row.family || "unknown";
if (!healthByFamily[family]) {
healthByFamily[family] = { total: 0, weighted: 0 };
}
// Calculate average health from quotaInfo (each entry has { pct })
// Health = average of all account quotas for this model
const quotaInfo = row.quotaInfo || [];
let avgHealth = 0;
if (quotaInfo.length > 0) {
avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
}
// If quotaInfo is empty, avgHealth remains 0 (depleted/unknown)
healthByFamily[family].total++;
healthByFamily[family].weighted += avgHealth;
totalHealthSum += avgHealth;
totalModelCount++;
});
// Update overall health for dashboard display
component.stats.overallHealth = totalModelCount > 0
? Math.round(totalHealthSum / totalModelCount)
: 0;
const familyColors = {
claude: getThemeColor("--color-neon-purple") || "#a855f7",
gemini: getThemeColor("--color-neon-green") || "#22c55e",
unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
};
const data = [];
const colors = [];
const labels = [];
const totalFamilies = Object.keys(healthByFamily).length;
const segmentSize = 100 / totalFamilies;
Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => {
const health = weighted / total;
const activeVal = (health / 100) * segmentSize;
const inactiveVal = segmentSize - activeVal;
const familyColor = familyColors[family] || familyColors["unknown"];
// Get translation keys
const store = Alpine.store("global");
const familyKey =
"family" + family.charAt(0).toUpperCase() + family.slice(1);
const familyName = store.t(familyKey);
// Labels using translations if possible
const activeLabel =
family === "claude"
? store.t("claudeActive")
: family === "gemini"
? store.t("geminiActive")
: `${familyName} ${store.t("activeSuffix")}`;
const depletedLabel =
family === "claude"
? store.t("claudeEmpty")
: family === "gemini"
? store.t("geminiEmpty")
: `${familyName} ${store.t("depleted")}`;
// Active segment
data.push(activeVal);
colors.push(familyColor);
labels.push(activeLabel);
// Inactive segment
data.push(inactiveVal);
// Use higher opacity (0.6) to ensure the ring color matches the legend more closely
// while still differentiating "depleted" from "active" (1.0 opacity)
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6));
labels.push(depletedLabel);
});
try {
component.charts.quotaDistribution = new Chart(canvas, {
type: "doughnut",
data: {
labels: labels,
datasets: [
{
data: data,
backgroundColor: colors,
borderColor: getThemeColor("--color-space-950"),
borderWidth: 2,
hoverOffset: 0,
borderRadius: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: "85%",
rotation: -90,
circumference: 360,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
title: { display: false },
},
animation: {
animateScale: true,
animateRotate: true,
},
},
});
} catch (e) {
console.error("Failed to create quota chart:", e);
}
};
/**
* Update usage trend line chart
* @param {object} component - Dashboard component instance
*/
window.DashboardCharts.updateTrendChart = function (component) {
// Prevent concurrent updates (fixes race condition on rapid toggling)
if (_trendChartUpdateLock) {
console.log("[updateTrendChart] Update already in progress, skipping");
return;
}
_trendChartUpdateLock = true;
console.log("[updateTrendChart] Starting update...");
// Safely destroy existing chart instance FIRST
if (component.charts.usageTrend) {
console.log("[updateTrendChart] Destroying existing chart");
try {
// Stop all animations before destroying to prevent null context errors
component.charts.usageTrend.stop();
component.charts.usageTrend.destroy();
} catch (e) {
console.error("[updateTrendChart] Failed to destroy chart:", e);
}
component.charts.usageTrend = null;
}
const canvas = document.getElementById("usageTrendChart");
// Safety checks
if (!canvas) {
console.error("[updateTrendChart] Canvas not found in DOM!");
return;
}
if (typeof Chart === "undefined") {
console.error("[updateTrendChart] Chart.js not loaded");
return;
}
console.log("[updateTrendChart] Canvas element:", {
exists: !!canvas,
isConnected: canvas.isConnected,
width: canvas.offsetWidth,
height: canvas.offsetHeight,
parentElement: canvas.parentElement?.tagName,
});
if (!isCanvasReady(canvas)) {
console.error("[updateTrendChart] Canvas not ready!", {
isConnected: canvas.isConnected,
width: canvas.offsetWidth,
height: canvas.offsetHeight,
});
_trendChartUpdateLock = false;
return;
}
// Clear canvas to ensure clean state after destroy
try {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
} catch (e) {
console.warn("[updateTrendChart] Failed to clear canvas:", e);
}
console.log(
"[updateTrendChart] Canvas is ready, proceeding with chart creation"
);
// Use filtered history data based on time range
const history = window.DashboardFilters.getFilteredHistoryData(component);
if (!history || Object.keys(history).length === 0) {
console.warn("No history data available for trend chart (after filtering)");
component.hasFilteredTrendData = false;
_trendChartUpdateLock = false;
return;
}
component.hasFilteredTrendData = true;
// Sort entries by timestamp for correct order
const sortedEntries = Object.entries(history).sort(
([a], [b]) => new Date(a).getTime() - new Date(b).getTime()
);
// Determine if data spans multiple days (for smart label formatting)
const timestamps = sortedEntries.map(([iso]) => new Date(iso));
const isMultiDay = timestamps.length > 1 &&
timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString();
// Helper to format X-axis labels based on time range and multi-day status
const formatLabel = (date) => {
const timeRange = component.timeRange || '24h';
if (timeRange === '7d') {
// Week view: show MM/DD
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
} else if (isMultiDay || timeRange === 'all') {
// Multi-day data: show MM/DD HH:MM
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
// Same day: show HH:MM only
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
};
const labels = [];
const datasets = [];
if (component.displayMode === "family") {
// Aggregate by family
const dataByFamily = {};
component.selectedFamilies.forEach((family) => {
dataByFamily[family] = [];
});
sortedEntries.forEach(([iso, hourData]) => {
const date = new Date(iso);
labels.push(formatLabel(date));
component.selectedFamilies.forEach((family) => {
const familyData = hourData[family];
const count = familyData?._subtotal || 0;
dataByFamily[family].push(count);
});
});
// Build datasets for families
component.selectedFamilies.forEach((family) => {
const color = window.DashboardFilters.getFamilyColor(family);
const familyKey =
"family" + family.charAt(0).toUpperCase() + family.slice(1);
const label = Alpine.store("global").t(familyKey);
datasets.push(
window.DashboardCharts.createDataset(
label,
dataByFamily[family],
color,
canvas
)
);
});
} else {
// Show individual models
const dataByModel = {};
// Initialize data arrays
component.families.forEach((family) => {
(component.selectedModels[family] || []).forEach((model) => {
const key = `${family}:${model}`;
dataByModel[key] = [];
});
});
sortedEntries.forEach(([iso, hourData]) => {
const date = new Date(iso);
labels.push(formatLabel(date));
component.families.forEach((family) => {
const familyData = hourData[family] || {};
(component.selectedModels[family] || []).forEach((model) => {
const key = `${family}:${model}`;
dataByModel[key].push(familyData[model] || 0);
});
});
});
// Build datasets for models
component.families.forEach((family) => {
(component.selectedModels[family] || []).forEach((model, modelIndex) => {
const key = `${family}:${model}`;
const color = window.DashboardFilters.getModelColor(family, modelIndex);
datasets.push(
window.DashboardCharts.createDataset(
model,
dataByModel[key],
color,
canvas
)
);
});
});
}
try {
component.charts.usageTrend = new Chart(canvas, {
type: "line",
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 300, // Reduced animation for faster updates
},
interaction: {
mode: "index",
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor:
getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)",
titleColor: getThemeColor("--color-text-main"),
bodyColor: getThemeColor("--color-text-bright"),
borderColor: getThemeColor("--color-space-border"),
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: function (context) {
return context.dataset.label + ": " + context.parsed.y;
},
},
},
},
scales: {
x: {
display: true,
grid: { display: false },
ticks: {
color: getThemeColor("--color-text-muted"),
font: { size: 10 },
},
},
y: {
display: true,
beginAtZero: true,
grid: {
display: true,
color:
getThemeColor("--color-space-border") + "1a" ||
"rgba(255,255,255,0.05)",
},
ticks: {
color: getThemeColor("--color-text-muted"),
font: { size: 10 },
},
},
},
},
});
} catch (e) {
console.error("Failed to create trend chart:", e);
} finally {
// Always release lock
_trendChartUpdateLock = false;
}
};