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 - gradual HSV-based color function getACWRColor(value: number | null): string { if (value === null || value === undefined) return 'rgba(148, 163, 184, 0.3)'; // ACWR thresholds for color zones const BLUE_THRESHOLD = 0.8; // Below this: detraining risk (blue) const GREEN_START = 0.8; // Green zone start (optimal range) const GREEN_END = 1.3; // Green zone end const ORANGE_END = 1.5; // Orange zone end (warning) // Above ORANGE_END: injury risk (red) // Hue values for colors const HUE_BLUE = 240; const HUE_GREEN = 120; const HUE_ORANGE = 40; const HUE_RED = 0; // Calculate transition centers const greenCenter = GREEN_START + (GREEN_END - GREEN_START) / 2; const orangeCenter = GREEN_END + (ORANGE_END - GREEN_END) / 2; // Linear interpolation helper with automatic normalization const lerp = (start: number, end: number, value: number, rangeStart: number, rangeEnd: number): number => { const t = (value - rangeStart) / (rangeEnd - rangeStart); return start * (1 - t) + end * t; }; let hue: number; if (value < BLUE_THRESHOLD) { hue = HUE_BLUE; } else if (value <= greenCenter) { hue = lerp(HUE_BLUE, HUE_GREEN, value, GREEN_START, greenCenter); } else if (value <= orangeCenter) { hue = lerp(HUE_GREEN, HUE_ORANGE, value, greenCenter, orangeCenter); } else if (value <= ORANGE_END) { hue = lerp(HUE_ORANGE, HUE_RED, value, orangeCenter, ORANGE_END); } else { hue = HUE_RED; } return `hsla(${hue}, 85%, 55%, 0.9)`; } // Plugin to draw gradient-colored ACWR line const acwrGradientPlugin = { id: 'acwrGradient', afterDatasetsDraw(chart: Chart) { // Find the ACWR dataset by label const acwrDatasetIndex = chart.data.datasets.findIndex(ds => ds.label === 'ACWR'); if (acwrDatasetIndex === -1) return; const meta = chart.getDatasetMeta(acwrDatasetIndex); if (!meta || meta.hidden) return; const ctx = chart.ctx; const data = chart.data.datasets[acwrDatasetIndex].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 = `${emoji}${displayName}`; 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 = ` πŸ—ΊοΈ Distance ${activity.distance.toFixed(2)} km `; 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 = ` ⏱️ Duration ${durationMin} min `; details.appendChild(durationDetail); } // TSS if (activity.trainingStressScore !== undefined && activity.trainingStressScore > 0) { const tssDetail = document.createElement('div'); tssDetail.className = 'activity-detail'; tssDetail.innerHTML = ` πŸ₯΅ TSS ${activity.trainingStressScore.toFixed(0)} `; details.appendChild(tssDetail); } // Calories if (activity.calories !== undefined && activity.calories > 0) { const caloriesDetail = document.createElement('div'); caloriesDetail.className = 'activity-detail'; caloriesDetail.innerHTML = ` πŸ”‹ Calories ${activity.calories.toFixed(0)} kcal `; details.appendChild(caloriesDetail); } // Average HR if (activity.averageHR !== undefined && activity.averageHR > 0) { const avgHRDetail = document.createElement('div'); avgHRDetail.className = 'activity-detail'; avgHRDetail.innerHTML = ` πŸ’š Avg HR ${activity.averageHR.toFixed(0)} bpm `; details.appendChild(avgHRDetail); } // Max HR if (activity.maxHR !== undefined && activity.maxHR > 0) { const maxHRDetail = document.createElement('div'); maxHRDetail.className = 'activity-detail'; maxHRDetail.innerHTML = ` ❀️ Max HR ${activity.maxHR.toFixed(0)} bpm `; 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`); // Combine historical and future data for charts const allDates = [...data.dates]; const allValues = [...data.values]; const allAverage7d = [...data.average7d]; const allAverage28d = [...data.average28d]; const allAcwr = [...data.acwr]; // Add future data if available if (data.futureDates && data.futureValues) { allDates.push(...data.futureDates); allValues.push(...data.futureValues); allAverage7d.push(...(data.futureAverage7d || [])); allAverage28d.push(...(data.futureAverage28d || [])); allAcwr.push(...(data.futureAcwr || [])); } const historicalCount = data.dates.length; const config: ChartConfiguration = { type: 'line', data: { labels: allDates, 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', }, // Future daily values (grey, larger points) - exclude zero values ...(data.futureValues ? [{ type: 'scatter' as const, label: `Predicted Daily ${metricLabel}`, data: Array(historicalCount).fill(null).concat( data.futureValues.map(v => (v !== null && v > 0) ? v : null) ), backgroundColor: 'rgba(148, 163, 184, 0.6)', borderColor: 'rgba(100, 116, 139, 0.8)', borderWidth: 2, pointRadius: 7, pointHoverRadius: 9, 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', }, // Future ACWR (grey, thicker) ...(data.futureAcwr ? [{ type: 'line' as const, label: 'Predicted ACWR', data: Array(historicalCount).fill(null).concat(data.futureAcwr), borderColor: 'rgba(148, 163, 184, 0.8)', backgroundColor: 'rgba(148, 163, 184, 0.1)', borderWidth: 5, pointRadius: 0, pointHoverRadius: 6, tension: 0.4, fill: false, yAxisID: 'y1', borderDash: [5, 5], }] : []), ], }, 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 = `🎯 Next activity : ACWR of ${targetACWR} cannot be reached`; } else if (targetValue === 0) { html = `🎯 Next activity : Rest recommended to reach ACWR of ${targetACWR}`; } else { const formattedValue = targetValue.toFixed(1); html = `🎯 Next activity : ${formattedValue} ${unit} to reach ACWR of ${targetACWR}`; } // Add rest tomorrow information if (restTomorrowACWR !== null && restTomorrowACWR !== undefined) { const color = getACWRColor(restTomorrowACWR); html += `
😴 If Rest : ACWR ${restTomorrowACWR.toFixed(2)}`; } element.innerHTML = html; element.classList.add('visible'); } else { element.innerHTML = `ℹ️ Target : Need at least 28 days of data to calculate`; 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; } }