glutamatt's picture
glutamatt HF Staff
ui
7f733b0 verified
raw
history blame
20 kB
import { Chart, ChartConfiguration, registerables } from 'chart.js';
import type { MetricACWRData, Activity } from '@/types';
// Register all Chart.js components
Chart.register(...registerables);
let distanceChart: Chart | null = null;
let durationChart: Chart | null = null;
let tssChart: Chart | null = null;
let caloriesChart: Chart | null = null;
// ACWR color zones
function getACWRColor(value: number | null): string {
if (value === null || value === undefined) return 'rgba(148, 163, 184, 0.3)';
if (value < 0.8) return 'rgba(59, 130, 246, 0.9)'; // Blue - Detraining risk
if (value <= 1.3) return 'rgba(16, 185, 129, 0.9)'; // Green - Optimal
if (value <= 1.5) return 'rgba(249, 115, 22, 0.9)'; // Orange - Warning
return 'rgba(239, 68, 68, 0.9)'; // Red - Injury risk
}
// Plugin to draw gradient-colored ACWR line
const acwrGradientPlugin = {
id: 'acwrGradient',
afterDatasetsDraw(chart: Chart) {
const meta = chart.getDatasetMeta(3); // ACWR is the 4th dataset (index 3)
if (!meta || meta.hidden) return;
const ctx = chart.ctx;
const data = chart.data.datasets[3].data as (number | null)[];
const chartArea = chart.chartArea;
ctx.save();
// First, fill the area under the curve with gradient colors
for (let i = 0; i < meta.data.length - 1; i++) {
const point1 = meta.data[i];
const point2 = meta.data[i + 1];
if (!point1 || !point2) continue;
const value1 = data[i];
const value2 = data[i + 1];
// Skip if both values are null
if (value1 === null && value2 === null) continue;
// Use the second point value (most recent/rightward)
const segmentValue = value2 ?? value1;
const color = getACWRColor(segmentValue);
// Make the fill more transparent
const fillColor = color.replace(/[\d.]+\)$/, '0.15)');
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.moveTo(point1.x, point1.y);
ctx.lineTo(point2.x, point2.y);
ctx.lineTo(point2.x, chartArea.bottom);
ctx.lineTo(point1.x, chartArea.bottom);
ctx.closePath();
ctx.fill();
}
// Then draw the line on top
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw line segments with colors based on values
for (let i = 0; i < meta.data.length - 1; i++) {
const point1 = meta.data[i];
const point2 = meta.data[i + 1];
if (!point1 || !point2) continue;
const value1 = data[i];
const value2 = data[i + 1];
// Skip if both values are null
if (value1 === null && value2 === null) continue;
// Use the second point value (most recent/rightward)
const segmentValue = value2 ?? value1;
ctx.strokeStyle = getACWRColor(segmentValue);
ctx.beginPath();
ctx.moveTo(point1.x, point1.y);
ctx.lineTo(point2.x, point2.y);
ctx.stroke();
}
ctx.restore();
},
};
// Register the custom plugin
Chart.register(acwrGradientPlugin);
// Function to get activity emoji based on type
function getActivityEmoji(activityType: string): string {
const type = activityType.toLowerCase();
if (type.includes('run')) return 'πŸƒ';
if (type.includes('cycling') || type.includes('bike') || type.includes('ride')) return '🚴';
if (type.includes('swim')) return '🏊';
if (type.includes('walk') || type.includes('hiking')) return '🚢';
if (type.includes('strength') || type.includes('gym')) return 'πŸ’ͺ';
if (type.includes('yoga')) return '🧘';
if (type.includes('row')) return '🚣';
if (type.includes('ski')) return '⛷️';
return '⚑'; // Default for other activities
}
// Function to show activity details popover
function showActivityDetails(dateStr: string, activities: Activity[]): void {
const popover = document.getElementById('activity-popover');
const dateTitle = document.getElementById('activity-date-title');
const activityList = document.getElementById('activity-list');
if (!popover || !dateTitle || !activityList) return;
// Format date
const date = new Date(dateStr);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
dateTitle.textContent = formattedDate;
// Clear previous activities
activityList.innerHTML = '';
// Add each activity
activities.forEach(activity => {
const activityItem = document.createElement('div');
activityItem.className = 'activity-item';
const displayName = activity.title || activity.activityType || 'Activity';
const emoji = getActivityEmoji(activity.activityType || 'Activity');
const header = document.createElement('div');
header.className = 'activity-item-header';
header.innerHTML = `<span>${emoji}</span><span>${displayName}</span>`;
const details = document.createElement('div');
details.className = 'activity-item-details';
// Distance
if (activity.distance !== undefined && activity.distance > 0) {
const distanceDetail = document.createElement('div');
distanceDetail.className = 'activity-detail';
distanceDetail.innerHTML = `
<span class="activity-detail-label">πŸ—ΊοΈ Distance</span>
<span class="activity-detail-value">${activity.distance.toFixed(2)} km</span>
`;
details.appendChild(distanceDetail);
}
// Duration
if (activity.duration !== undefined && activity.duration > 0) {
const durationDetail = document.createElement('div');
durationDetail.className = 'activity-detail';
const durationMin = Math.round(activity.duration);
durationDetail.innerHTML = `
<span class="activity-detail-label">⏱️ Duration</span>
<span class="activity-detail-value">${durationMin} min</span>
`;
details.appendChild(durationDetail);
}
// TSS
if (activity.trainingStressScore !== undefined && activity.trainingStressScore > 0) {
const tssDetail = document.createElement('div');
tssDetail.className = 'activity-detail';
tssDetail.innerHTML = `
<span class="activity-detail-label">πŸ₯΅ TSS</span>
<span class="activity-detail-value">${activity.trainingStressScore.toFixed(0)}</span>
`;
details.appendChild(tssDetail);
}
// Calories
if (activity.calories !== undefined && activity.calories > 0) {
const caloriesDetail = document.createElement('div');
caloriesDetail.className = 'activity-detail';
caloriesDetail.innerHTML = `
<span class="activity-detail-label">πŸ”‹ Calories</span>
<span class="activity-detail-value">${activity.calories.toFixed(0)} kcal</span>
`;
details.appendChild(caloriesDetail);
}
// Average HR
if (activity.averageHR !== undefined && activity.averageHR > 0) {
const avgHRDetail = document.createElement('div');
avgHRDetail.className = 'activity-detail';
avgHRDetail.innerHTML = `
<span class="activity-detail-label">πŸ’š Avg HR</span>
<span class="activity-detail-value">${activity.averageHR.toFixed(0)} bpm</span>
`;
details.appendChild(avgHRDetail);
}
// Max HR
if (activity.maxHR !== undefined && activity.maxHR > 0) {
const maxHRDetail = document.createElement('div');
maxHRDetail.className = 'activity-detail';
maxHRDetail.innerHTML = `
<span class="activity-detail-label">❀️ Max HR</span>
<span class="activity-detail-value">${activity.maxHR.toFixed(0)} bpm</span>
`;
details.appendChild(maxHRDetail);
}
activityItem.appendChild(header);
activityItem.appendChild(details);
activityList.appendChild(activityItem);
});
// Show popover
popover.classList.remove('hidden');
}
// Setup activity popover close handlers
function setupActivityPopoverHandlers(): void {
const popover = document.getElementById('activity-popover');
const closeButton = document.getElementById('activity-close');
if (!popover || !closeButton) return;
// Close button click
closeButton.addEventListener('click', () => {
popover.classList.add('hidden');
});
// Click outside to close
popover.addEventListener('click', (e) => {
if (e.target === popover) {
popover.classList.add('hidden');
}
});
// Escape key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !popover.classList.contains('hidden')) {
popover.classList.add('hidden');
}
});
}
// Initialize handlers on load
if (typeof window !== 'undefined') {
setupActivityPopoverHandlers();
}
// Common chart styling inspired by Garmin
const commonOptions = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: true,
position: 'top' as const,
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
},
tooltip: {
mode: 'index' as const,
intersect: false,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: '#1e293b',
bodyColor: '#475569',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: true,
},
},
scales: {
x: {
grid: {
display: true,
color: 'rgba(148, 163, 184, 0.15)',
lineWidth: 1,
drawBorder: false,
},
ticks: {
maxRotation: 45,
minRotation: 45,
padding: 8,
color: '#64748b',
font: {
size: 10,
},
autoSkip: true,
maxTicksLimit: 20,
},
},
},
};
function createDualAxisChart(
canvasId: string,
data: MetricACWRData,
metricLabel: string,
metricUnit: string,
dotColor: string
): Chart {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
const config: ChartConfiguration = {
type: 'line',
data: {
labels: data.dates,
datasets: [
{
type: 'scatter',
label: `Daily ${metricLabel}`,
data: data.values,
backgroundColor: dotColor,
borderColor: dotColor.replace('0.8)', '1)'),
borderWidth: 1,
pointRadius: 5,
pointHoverRadius: 7,
yAxisID: 'y',
},
{
type: 'line',
label: '7-Day Average',
data: data.average7d,
borderColor: 'rgba(168, 85, 247, 0.5)',
backgroundColor: 'rgba(168, 85, 247, 0.05)',
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.4,
fill: false,
yAxisID: 'y',
},
{
type: 'line',
label: '28-Day Average',
data: data.average28d,
borderColor: 'rgba(236, 72, 153, 0.5)',
backgroundColor: 'rgba(236, 72, 153, 0.05)',
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.4,
fill: false,
yAxisID: 'y',
},
{
type: 'line',
label: 'ACWR',
data: data.acwr,
borderColor: 'rgba(139, 92, 246, 0)', // Transparent - we draw it in the plugin
backgroundColor: 'rgba(139, 92, 246, 0)',
borderWidth: 4,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: (context: any) => {
const value = context.raw;
return getACWRColor(value);
},
tension: 0.4,
fill: false,
yAxisID: 'y1',
},
],
},
options: {
...commonOptions,
onClick: (_event: any, elements: any[]) => {
if (elements.length > 0) {
const element = elements[0];
const datasetIndex = element.datasetIndex;
// Only handle clicks on the scatter (daily values) dataset
if (datasetIndex === 0) {
const index = element.index;
const dateStr = data.dates[index];
// Get activities for this date
if (data.activitiesByDate) {
const activities = data.activitiesByDate.get(dateStr);
if (activities && activities.length > 0) {
showActivityDetails(dateStr, activities);
}
}
}
}
},
scales: {
...commonOptions.scales,
y: {
type: 'linear',
position: 'left',
beginAtZero: true,
border: {
display: false,
},
grid: {
color: 'rgba(148, 163, 184, 0.1)',
},
ticks: {
padding: 8,
color: '#64748b',
font: {
size: 11,
},
},
title: {
display: true,
text: `${metricLabel} ${metricUnit}`,
color: '#64748b',
font: {
size: 12,
weight: 500,
},
},
},
y1: {
type: 'linear',
position: 'right',
beginAtZero: true,
suggestedMin: 0,
suggestedMax: 2,
grid: {
drawOnChartArea: false,
},
ticks: {
padding: 8,
color: '#8b5cf6',
font: {
size: 11,
},
},
title: {
display: true,
text: 'ACWR',
color: '#8b5cf6',
font: {
size: 12,
weight: 500,
},
},
},
},
interaction: {
mode: 'index' as const,
intersect: false,
},
},
};
return new Chart(canvas, config);
}
export function createDistanceChart(data: MetricACWRData): void {
if (distanceChart) {
distanceChart.destroy();
}
distanceChart = createDualAxisChart(
'distance-chart',
data,
'Distance',
'(km)',
'rgba(234, 179, 8, 0.8)'
);
updateTargetInfo('distance-target', data.targetTomorrowValue, 'km', data.targetACWR, data.restTomorrowACWR);
}
export function createDurationChart(data: MetricACWRData): void {
if (durationChart) {
durationChart.destroy();
}
durationChart = createDualAxisChart(
'duration-chart',
data,
'Duration',
'(min)',
'rgba(234, 179, 8, 0.8)'
);
updateTargetInfo('duration-target', data.targetTomorrowValue, 'minutes', data.targetACWR, data.restTomorrowACWR);
}
export function createTSSChart(data: MetricACWRData): void {
if (tssChart) {
tssChart.destroy();
}
tssChart = createDualAxisChart(
'tss-chart',
data,
'TSS',
'',
'rgba(234, 179, 8, 0.8)'
);
updateTargetInfo('tss-target', data.targetTomorrowValue, 'TSS', data.targetACWR, data.restTomorrowACWR);
}
export function createCaloriesChart(data: MetricACWRData): void {
if (caloriesChart) {
caloriesChart.destroy();
}
caloriesChart = createDualAxisChart(
'calories-chart',
data,
'Calories',
'(kcal)',
'rgba(234, 179, 8, 0.8)'
);
updateTargetInfo('calories-target', data.targetTomorrowValue, 'kcal', data.targetACWR, data.restTomorrowACWR);
}
function updateTargetInfo(elementId: string, targetValue: number | null | undefined, unit: string, targetACWR: number | undefined, restTomorrowACWR: number | null | undefined): void {
const element = document.getElementById(elementId);
if (!element) return;
if (targetACWR !== undefined) {
let html: string;
if (targetValue === null || targetValue === undefined) {
html = `<strong>πŸ’‘ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Target ACWR of ${targetACWR} cannot be reached in one day</span>`;
} else if (targetValue === 0) {
html = `<strong>πŸ’‘ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Rest day recommended to reach ACWR of ${targetACWR}</span>`;
} else {
const formattedValue = targetValue.toFixed(1);
html = `<strong>πŸ’‘ Target for tomorrow:</strong> <span class="target-value">${formattedValue} ${unit}</span> <span style="color: var(--secondary-color);">to reach ACWR of ${targetACWR}</span>`;
}
// Add rest tomorrow information
if (restTomorrowACWR !== null && restTomorrowACWR !== undefined) {
const color = getACWRColor(restTomorrowACWR);
html += `<br><strong>😴 Rest tomorrow:</strong> <span style="color: ${color};">ACWR ${restTomorrowACWR.toFixed(2)}</span>`;
}
element.innerHTML = html;
element.classList.add('visible');
} else {
element.innerHTML = `<strong>ℹ️ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Need at least 28 days of data to calculate</span>`;
element.classList.add('visible');
}
}
export function destroyAllCharts(): void {
if (distanceChart) {
distanceChart.destroy();
distanceChart = null;
}
if (durationChart) {
durationChart.destroy();
durationChart = null;
}
if (tssChart) {
tssChart.destroy();
tssChart = null;
}
if (caloriesChart) {
caloriesChart.destroy();
caloriesChart = null;
}
}