glutamatt's picture
glutamatt HF Staff
kcal to cal ( wtf kcal ! )
62a5cee verified
raw
history blame
30.4 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 - 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;
}
}