Spaces:
Running
Running
| 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 = ` | |
| <span style="color: ${color}; font-weight: bold; background-color: ${bgColor}">${acwrValue.toFixed(2)}</span> | |
| <span style="font-size: 0.7em; opacity: 0.8;">(${rangeName})</span> | |
| `; | |
| } | |
| // 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 = `<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)} cal</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, | |
| }, | |
| 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 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: (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)' | |
| ); | |
| updateTargetInfo('distance-target', data.targetTomorrowValue, 'km', data.targetACWR, data.restTomorrowACWR); | |
| // 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)' | |
| ); | |
| updateTargetInfo('duration-target', data.targetTomorrowValue, 'minutes', data.targetACWR, data.restTomorrowACWR); | |
| // 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)' | |
| ); | |
| updateTargetInfo('tss-target', data.targetTomorrowValue, 'TSS', 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)' | |
| ); | |
| updateTargetInfo('calories-target', data.targetTomorrowValue, 'cal', data.targetACWR, data.restTomorrowACWR); | |
| // 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, 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>π― Next activity :</strong> <span style="color: var(--secondary-color);">ACWR of ${targetACWR} cannot be reached</span>`; | |
| } else if (targetValue === 0) { | |
| html = `<strong>π― Next activity :</strong> <span style="color: var(--secondary-color);">Rest recommended to reach ACWR of ${targetACWR}</span>`; | |
| } else { | |
| const formattedValue = targetValue.toFixed(1); | |
| html = `<strong>π― Next activity :</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>π΄ If Rest :</strong> <span style="color: ${color};">ACWR ${restTomorrowACWR.toFixed(2)}</span>`; | |
| } | |
| element.innerHTML = html; | |
| element.classList.add('visible'); | |
| } else { | |
| element.innerHTML = `<strong>βΉοΈ Target :</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; | |
| } | |
| } | |