Spaces:
Running
Running
| import { Chart, ChartConfiguration, registerables } from 'chart.js'; | |
| import type { MetricACWRData } 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; | |
| // 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); | |
| // 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, | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| function updateTargetInfo(elementId: string, targetValue: number | null | undefined, unit: string, targetACWR: number | undefined): void { | |
| const element = document.getElementById(elementId); | |
| if (!element) return; | |
| if (targetValue !== null && targetValue !== undefined && targetACWR !== undefined) { | |
| const formattedValue = targetValue.toFixed(1); | |
| element.innerHTML = `<strong>💡 Target for tomorrow:</strong> <span class="target-value">${formattedValue} ${unit}</span> <span style="color: var(--secondary-color);">to reach ACWR of ${targetACWR}</span>`; | |
| 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; | |
| } | |
| } | |