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, alpha: number = .99): 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%, 65%, ${alpha})`; } // Get ACWR range name function getACWRRangeName(value: number | null): string { if (value === null || value === undefined) return ''; if (value < 0.8) return 'Detraining risk'; if (value <= 1.3) return 'Optimal'; if (value <= 1.5) return 'Warning'; return 'Injury risk'; } // Update ACWR display in chart title function updateACWRDisplay(elementId: string, acwrValue: number | null): void { const element = document.getElementById(elementId); if (!element) return; if (acwrValue === null || acwrValue === undefined) { element.innerHTML = ''; return; } const color = getACWRColor(acwrValue); const bgColor = getACWRColor(acwrValue, .12); const rangeName = getACWRRangeName(acwrValue); element.innerHTML = ` ${acwrValue.toFixed(2)} (${rangeName}) `; } // 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; // Find the range of non-null ACWR values for opacity gradient let firstNonNullIndex = -1; let lastNonNullIndex = -1; for (let i = 0; i < data.length; i++) { if (data[i] !== null) { if (firstNonNullIndex === -1) firstNonNullIndex = i; lastNonNullIndex = i; } } // Calculate total span of ACWR data const acwrSpan = lastNonNullIndex - firstNonNullIndex; 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; // Skip if the first value is null (start of ACWR curve) if (value1 === null) continue; // Use the second point value (most recent/rightward) const segmentValue = value2 ?? value1; // Calculate opacity based on position: start at 0.05, end at 0.25 let opacity = 0.25; if (acwrSpan > 0 && i >= firstNonNullIndex && i <= lastNonNullIndex) { const progress = (i - firstNonNullIndex) / acwrSpan; opacity = 0.05 + (progress * 0.20); // 0.05 to 0.25 } const color = getACWRColor(segmentValue); // Apply variable opacity to the fill const fillColor = color.replace(/[\d.]+\)$/, `${opacity})`); 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 and variable opacity 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; // Skip if the first value is null (start of ACWR curve) if (value1 === null) continue; // Use the second point value (most recent/rightward) const segmentValue = value2 ?? value1; // Calculate opacity based on position: start at 0.20, end at 0.99 let opacity = 0.99; if (acwrSpan > 0 && i >= firstNonNullIndex && i <= lastNonNullIndex) { const progress = (i - firstNonNullIndex) / acwrSpan; opacity = 0.20 + (progress * 0.79); // 0.20 to 0.99 } const color = getACWRColor(segmentValue); const strokeColor = color.replace(/[\d.]+\)$/, `${opacity})`); ctx.strokeStyle = strokeColor; 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); // Plugin to color y1 (ACWR) axis labels with gradient colors const acwrAxisColorPlugin = { id: 'acwrAxisColor', afterDraw(chart: Chart) { const y1Scale = chart.scales['y1']; if (!y1Scale) return; const ctx = chart.ctx; ctx.save(); // Get the tick values and positions const ticks = y1Scale.ticks; ticks.forEach((tick: any) => { const value = tick.value; const color = getACWRColor(value); // Find the tick label element and color it const tickLabel = y1Scale.getPixelForValue(value); if (tickLabel !== undefined) { ctx.fillStyle = color; } }); ctx.restore(); }, }; // Register the axis color plugin Chart.register(acwrAxisColorPlugin); // Plugin to draw one vertical lines per week function weekGridPlugin(dayOfWeek = 1) { // 0:sunday, 1:monday, ... , 6:saturday return { id: 'weekGrid', beforeDatasetsDraw(chart: Chart) { const ctx = chart.ctx; const chartArea = chart.chartArea; const xScale = chart.scales['x']; if (!xScale || !chartArea) return; ctx.save(); ctx.strokeStyle = 'rgba(148, 163, 184, 0.3)'; ctx.lineWidth = 1; // Draw a line for each matching day in the data const labels = chart.data.labels || []; labels.forEach((label, index) => { if (typeof label === 'string') { const [year, month, day] = label.split('-').map(Number); const date = new Date(year, month - 1, day); // If it's Monday, draw a vertical line if (date.getDay() === dayOfWeek) { const x = xScale.getPixelForValue(index); if (x >= chartArea.left && x <= chartArea.right) { ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); } } } }); ctx.restore(); }, } }; // Register the week grid plugin const GRID_DAY_OF_WEEK = 0;//sunday Chart.register(weekGridPlugin(GRID_DAY_OF_WEEK)); // 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)} cal `; 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, }, color: '#94a3b8', }, }, 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, callbacks: { label: function (context: any) { const label = context.dataset.label || ''; const value = context.parsed.y; // For daily values (scatter datasets), show only the value if (label.includes('Daily')) { return `${label}: ${value !== null ? value.toFixed(2) : 'N/A'}`; } // For other datasets, show default format return `${label}: ${value !== null ? value.toFixed(2) : 'N/A'}`; } } }, }, scales: { x: { grid: { display: false, // Disable default grid, we draw Monday lines with plugin drawBorder: false, }, ticks: { maxRotation: 45, minRotation: 45, padding: 8, color: '#64748b', font: { size: 10, }, autoSkip: false, callback: function (_value: any, index: number): string { const labels = (this as any).chart.data.labels || []; const label = labels[index]; if (typeof label === 'string') { const [year, month, day] = label.split('-').map(Number); const date = new Date(year, month - 1, day); // Show tick only on vertical grid lines if (date.getDay() === GRID_DAY_OF_WEEK) { return `${day}/${month}`; } } return ''; }, }, }, }, }; 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 ALL future dates to maintain x-axis continuity // Use allFuture* arrays for complete data including ACWR curve if (data.allFutureDates && data.allFutureValues) { const MIN_THRESHOLD = 1; // Use the complete future data that includes all days data.allFutureDates.forEach((date, i) => { allDates.push(date); // Only show activity value if above threshold const value = data.allFutureValues![i]; allValues.push(value >= MIN_THRESHOLD ? value : null); // Include all ACWR and average data for curve continuity allAverage7d.push(data.allFutureAverage7d?.[i] ?? null); allAverage28d.push(data.allFutureAverage28d?.[i] ?? null); allAcwr.push(data.allFutureAcwr?.[i] ?? null); }); } 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) - filtered to exclude insignificant values ...(data.futureValues && data.futureValues.some(v => v !== null && v >= 1) ? [{ type: 'scatter' as const, label: `Predicted Daily ${metricLabel}`, data: Array(historicalCount).fill(null).concat( allValues.slice(historicalCount) ), 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) - filtered to match future values ...(data.futureAcwr && allAcwr.length > historicalCount ? [{ type: 'line' as const, label: 'Predicted ACWR', data: Array(historicalCount).fill(null).concat( allAcwr.slice(historicalCount) ), 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: (context: any) => { // Color each tick based on its value using ACWR gradient return getACWRColor(context.tick.value); }, font: { size: 11, }, }, title: { display: true, text: 'ACWR', color: '#4ade80', 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)' ); // Use first predicted value if it's for tomorrow/today, otherwise use targetTomorrowValue updateTargetInfo('distance-target', data, 'km'); // Update ACWR display in title const currentACWR = data.acwr.length > 0 ? data.acwr[data.acwr.length - 1] : null; updateACWRDisplay('distance-acwr-display', currentACWR); } export function createDurationChart(data: MetricACWRData): void { if (durationChart) { durationChart.destroy(); } durationChart = createDualAxisChart( 'duration-chart', data, 'Duration', '(min)', 'rgba(234, 179, 8, 0.8)' ); // Use first predicted value if it's for tomorrow/today, otherwise use targetTomorrowValue updateTargetInfo('duration-target', data, 'minutes'); // Update ACWR display in title const currentACWR = data.acwr.length > 0 ? data.acwr[data.acwr.length - 1] : null; updateACWRDisplay('duration-acwr-display', currentACWR); } export function createTSSChart(data: MetricACWRData): void { if (tssChart) { tssChart.destroy(); } tssChart = createDualAxisChart( 'tss-chart', data, 'TSS', '', 'rgba(234, 179, 8, 0.8)' ); // Use first predicted value if it's for tomorrow/today, otherwise use targetTomorrowValue updateTargetInfo('tss-target', data, 'TSS'); // Update ACWR display in titleet', nextDistanceValue, 'km', data.targetACWR, data.restTomorrowACWR); // Update ACWR display in title const currentACWR = data.acwr.length > 0 ? data.acwr[data.acwr.length - 1] : null; updateACWRDisplay('tss-acwr-display', currentACWR); } export function createCaloriesChart(data: MetricACWRData): void { if (caloriesChart) { caloriesChart.destroy(); } caloriesChart = createDualAxisChart( 'calories-chart', data, 'Calories', '', 'rgba(234, 179, 8, 0.8)' ); // Use first predicted value if it's for tomorrow/today, otherwise use targetTomorrowValue updateTargetInfo('calories-target', data, 'cal'); // Update ACWR display in title const currentACWR = data.acwr.length > 0 ? data.acwr[data.acwr.length - 1] : null; updateACWRDisplay('calories-acwr-display', currentACWR); } function updateTargetInfo(elementId: string, data: MetricACWRData, unit: string): void { const element = document.getElementById(elementId); if (!element) return; if (data.targetACWR !== undefined) { let html: string; let targetValue: number | null | undefined; // Check if first prediction is for tomorrow (day after last date in data) if (data.futureDates && data.futureDates.length > 0 && data.futureValues && data.futureValues.length > 0) { const lastHistoricalDate = data.dates[data.dates.length - 1]; const firstPredictionDate = data.futureDates[0]; // Calculate tomorrow's date from last historical date const lastDate = new Date(lastHistoricalDate); const tomorrowDate = new Date(lastDate); tomorrowDate.setDate(lastDate.getDate() + 1); const tomorrowStr = tomorrowDate.toISOString().split('T')[0]; // If first prediction is for tomorrow, use it; otherwise use targetTomorrowValue if (firstPredictionDate === tomorrowStr) { targetValue = data.futureValues[0]; } else { // First prediction is not for tomorrow, so tomorrow must be a rest day targetValue = data.targetTomorrowValue; } } else { targetValue = data.targetTomorrowValue; } if (targetValue === null || targetValue === undefined) { html = `🎯 Next activity : ACWR of ${data.targetACWR} cannot be reached`; } else if (targetValue === 0) { html = `🎯 Next activity : Rest recommended to reach ACWR of ${data.targetACWR}`; } else { const formattedValue = targetValue.toFixed(1); html = `🎯 Next activity : ${formattedValue} ${unit} to reach ACWR of ${data.targetACWR}`; } // Add rest tomorrow information if (data.restTomorrowACWR !== null && data.restTomorrowACWR !== undefined) { const color = getACWRColor(data.restTomorrowACWR); html += `
😴 If Rest : ACWR ${data.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; } }