Files
sport-science-coach/js/charts.js
T
2025-04-27 14:31:03 +02:00

701 lines
25 KiB
JavaScript

/**
* Charts Module
* Handles the creation and rendering of data visualizations for health reports
*/
class ChartsManager {
constructor() {
// Store references to created charts for cleanup
this.charts = [];
// Define chart colors
this.colors = {
primary: '#4a6fa5',
secondary: '#6fb98f',
accent: '#ff7e67',
dark: '#2c3e50',
light: '#ecf0f1',
sleep: {
total: '#4a6fa5',
deep: '#2c3e50',
light: '#95a5a6',
rem: '#3498db',
awake: '#e74c3c'
},
stress: {
calm: '#2ecc71',
balanced: '#3498db',
stressful: '#f39c12',
veryStressful: '#e74c3c'
}
};
}
/**
* Create all charts for the report
* @param {Object} data - Processed health and workout data
* @param {HTMLElement} container - Container element for the charts
*/
createCharts(data, container) {
// Clear any existing charts
this.clearCharts();
// Create chart sections
const sleepSection = this.createChartSection('Sleep Patterns', container);
const stressSection = this.createChartSection('Stress Levels', container);
const workoutSection = this.createChartSection('Workout Analysis', container);
const hrvSection = this.createChartSection('Recovery Metrics', container);
// Create charts
this.createSleepChart(data.metrics, sleepSection);
this.createStressChart(data.metrics, stressSection);
this.createWorkoutChart(data.workouts, workoutSection);
this.createHRVChart(data.metrics, hrvSection);
}
/**
* Create a section for a chart
* @param {string} title - Section title
* @param {HTMLElement} container - Parent container
* @returns {HTMLElement} - The charts wrapper element
*/
createChartSection(title, container) {
const section = document.createElement('div');
section.className = 'chart-section';
const heading = document.createElement('h3');
heading.textContent = title;
section.appendChild(heading);
// Create a wrapper for all charts in this section
const chartsWrapper = document.createElement('div');
chartsWrapper.className = 'charts-wrapper';
section.appendChild(chartsWrapper);
container.appendChild(section);
return chartsWrapper;
}
/**
* Create an individual chart container
* @param {HTMLElement} wrapper - Parent wrapper element
* @returns {HTMLElement} - The chart container element
*/
createChartContainer(wrapper) {
const chartContainer = document.createElement('div');
chartContainer.className = 'chart-container';
wrapper.appendChild(chartContainer);
return chartContainer;
}
/**
* Create sleep patterns chart
* @param {Array} metricsData - Processed metrics data
* @param {HTMLElement} wrapper - Wrapper for the chart containers
*/
createSleepChart(metricsData, wrapper) {
// Filter out days without sleep data
const sleepData = metricsData.filter(day =>
day.metrics['Sleep Hours'] !== undefined
);
if (sleepData.length === 0) {
this.showNoDataMessage(wrapper, 'No sleep data available');
return;
}
// Prepare data for chart
const dates = sleepData.map(day => day.date);
const totalSleep = sleepData.map(day => day.metrics['Sleep Hours']);
const deepSleep = sleepData.map(day => day.metrics['Time In Deep Sleep'] || 0);
const lightSleep = sleepData.map(day => day.metrics['Time In Light Sleep'] || 0);
const remSleep = sleepData.map(day => day.metrics['Time In REM Sleep'] || 0);
// Create container for stacked bar chart
const stackedContainer = this.createChartContainer(wrapper);
const canvas = document.createElement('canvas');
stackedContainer.appendChild(canvas);
// Create chart
const chart = new Chart(canvas, {
type: 'bar',
data: {
labels: dates,
datasets: [
{
label: 'Deep Sleep',
data: deepSleep,
backgroundColor: this.colors.sleep.deep,
stack: 'sleep'
},
{
label: 'Light Sleep',
data: lightSleep,
backgroundColor: this.colors.sleep.light,
stack: 'sleep'
},
{
label: 'REM Sleep',
data: remSleep,
backgroundColor: this.colors.sleep.rem,
stack: 'sleep'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Sleep Composition by Night'
},
tooltip: {
mode: 'index',
callbacks: {
afterTitle: (items) => {
const index = items[0].dataIndex;
return `Total: ${totalSleep[index]} hours`;
}
}
}
},
scales: {
x: {
stacked: true,
title: {
display: true,
text: 'Date'
}
},
y: {
stacked: true,
title: {
display: true,
text: 'Hours'
}
}
}
}
});
// Store chart reference
this.charts.push(chart);
// Create container for trend line chart
const trendContainer = this.createChartContainer(wrapper);
const trendCanvas = document.createElement('canvas');
trendContainer.appendChild(trendCanvas);
const trendChart = new Chart(trendCanvas, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: 'Sleep Duration',
data: totalSleep,
borderColor: this.colors.sleep.total,
backgroundColor: 'rgba(74, 111, 165, 0.1)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Sleep Duration Trend'
}
},
scales: {
y: {
title: {
display: true,
text: 'Hours'
},
min: Math.max(0, Math.min(...totalSleep) - 1),
max: Math.max(...totalSleep) + 1
}
}
}
});
// Store chart reference
this.charts.push(trendChart);
}
/**
* Create stress levels chart
* @param {Array} metricsData - Processed metrics data
* @param {HTMLElement} wrapper - Wrapper for the chart containers
*/
createStressChart(metricsData, wrapper) {
// Filter out days without stress data
const stressData = metricsData.filter(day =>
day.metrics['Stress Qualifier'] !== undefined
);
if (stressData.length === 0) {
this.showNoDataMessage(wrapper, 'No stress data available');
return;
}
// Prepare data for chart
const dates = stressData.map(day => day.date);
const stressQualifiers = stressData.map(day => day.metrics['Stress Qualifier']);
// Count occurrences of each stress level
const stressLevels = ['calm', 'balanced', 'stressful', 'very_stressful'];
const stressCounts = {};
stressLevels.forEach(level => {
stressCounts[level] = stressQualifiers.filter(q => q === level).length;
});
// Create container for pie chart
const pieContainer = this.createChartContainer(wrapper);
const pieCanvas = document.createElement('canvas');
pieContainer.appendChild(pieCanvas);
// Create pie chart
const pieChart = new Chart(pieCanvas, {
type: 'doughnut',
data: {
labels: ['Calm', 'Balanced', 'Stressful', 'Very Stressful'],
datasets: [{
data: [
stressCounts.calm || 0,
stressCounts.balanced || 0,
stressCounts.stressful || 0,
stressCounts.very_stressful || 0
],
backgroundColor: [
this.colors.stress.calm,
this.colors.stress.balanced,
this.colors.stress.stressful,
this.colors.stress.veryStressful
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Stress Level Distribution'
},
legend: {
position: 'right'
}
}
}
});
// Store chart reference
this.charts.push(pieChart);
// Create container for timeline chart
const timelineContainer = this.createChartContainer(wrapper);
const timelineCanvas = document.createElement('canvas');
timelineContainer.appendChild(timelineCanvas);
// Map stress qualifiers to numeric values for the chart
const stressValues = stressQualifiers.map(qualifier => {
switch(qualifier) {
case 'calm': return 1;
case 'balanced': return 2;
case 'stressful': return 3;
case 'very_stressful': return 4;
default: return 0;
}
});
const timelineChart = new Chart(timelineCanvas, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: 'Stress Level',
data: stressValues,
borderColor: this.colors.accent,
backgroundColor: 'rgba(255, 126, 103, 0.1)',
fill: true,
stepped: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Stress Level Timeline'
},
tooltip: {
callbacks: {
label: (context) => {
const value = context.raw;
let label = '';
switch(value) {
case 1: label = 'Calm'; break;
case 2: label = 'Balanced'; break;
case 3: label = 'Stressful'; break;
case 4: label = 'Very Stressful'; break;
default: label = 'Unknown';
}
return label;
}
}
}
},
scales: {
y: {
min: 0.5,
max: 4.5,
ticks: {
callback: (value) => {
switch(value) {
case 1: return 'Calm';
case 2: return 'Balanced';
case 3: return 'Stressful';
case 4: return 'Very Stressful';
default: return '';
}
}
}
}
}
}
});
// Store chart reference
this.charts.push(timelineChart);
}
/**
* Create workout analysis chart
* @param {Array} workoutsData - Processed workouts data
* @param {HTMLElement} wrapper - Wrapper for the chart containers
*/
createWorkoutChart(workoutsData, wrapper) {
if (workoutsData.length === 0) {
this.showNoDataMessage(wrapper, 'No workout data available');
return;
}
// Prepare data for chart
const dates = workoutsData.map(workout => workout.date);
const heartRates = workoutsData.map(workout => workout.heartRate.average);
const durations = workoutsData.map(workout => workout.duration * 60); // Convert to minutes
// Create container for bar/line chart
const barContainer = this.createChartContainer(wrapper);
const canvas = document.createElement('canvas');
barContainer.appendChild(canvas);
// Create chart
const chart = new Chart(canvas, {
type: 'bar',
data: {
labels: dates,
datasets: [
{
label: 'Duration (minutes)',
data: durations,
backgroundColor: this.colors.secondary,
yAxisID: 'y',
order: 1 // Draw bars first (underneath)
},
{
label: 'Avg Heart Rate (bpm)',
data: heartRates,
borderColor: this.colors.accent,
backgroundColor: 'transparent', // No fill
borderWidth: 3, // Thicker line
pointRadius: 4, // Visible points
pointBackgroundColor: this.colors.accent,
pointBorderColor: '#fff',
pointHoverRadius: 6,
pointHoverBackgroundColor: this.colors.accent,
tension: 0.3, // Slightly curved line
type: 'line',
yAxisID: 'y1',
order: 0 // Draw line last (on top)
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
title: {
display: true,
text: 'Workout Duration and Intensity'
},
tooltip: {
callbacks: {
title: (tooltipItems) => {
return `Date: ${tooltipItems[0].label}`;
}
}
}
},
scales: {
y: {
type: 'linear',
position: 'left',
title: {
display: true,
text: 'Duration (minutes)'
},
min: 0
},
y1: {
type: 'linear',
position: 'right',
title: {
display: true,
text: 'Heart Rate (bpm)'
},
min: Math.max(0, Math.min(...heartRates) - 10),
max: Math.max(...heartRates) + 10,
grid: {
drawOnChartArea: false
}
}
}
}
});
// Store chart reference
this.charts.push(chart);
// Create workout type distribution chart if we have type data
if (workoutsData.some(workout => workout.type)) {
// Count workout types
const workoutTypes = {};
workoutsData.forEach(workout => {
const type = workout.type || 'Unknown';
workoutTypes[type] = (workoutTypes[type] || 0) + 1;
});
// Create container for pie chart
const pieContainer = this.createChartContainer(wrapper);
const pieCanvas = document.createElement('canvas');
pieContainer.appendChild(pieCanvas);
// Create color array based on number of workout types
const typeColors = Object.keys(workoutTypes).map((_, index) => {
const hue = (index * 137) % 360; // Golden angle approximation for good distribution
return `hsl(${hue}, 70%, 60%)`;
});
// Create pie chart
const pieChart = new Chart(pieCanvas, {
type: 'pie',
data: {
labels: Object.keys(workoutTypes),
datasets: [{
data: Object.values(workoutTypes),
backgroundColor: typeColors
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Workout Type Distribution'
},
legend: {
position: 'right'
}
}
}
});
// Store chart reference
this.charts.push(pieChart);
}
}
/**
* Create HRV and recovery metrics chart
* @param {Array} metricsData - Processed metrics data
* @param {HTMLElement} wrapper - Wrapper for the chart containers
*/
createHRVChart(metricsData, wrapper) {
// Filter out days without HRV data
const hrvData = metricsData.filter(day =>
day.metrics['HRV'] !== undefined
);
if (hrvData.length === 0) {
this.showNoDataMessage(wrapper, 'No HRV data available');
return;
}
// Prepare data for chart
const dates = hrvData.map(day => day.date);
const hrvValues = hrvData.map(day => day.metrics['HRV']);
const pulseValues = hrvData.map(day => day.metrics['Pulse']);
// Create container for HRV/Pulse chart
const hrvContainer = this.createChartContainer(wrapper);
const canvas = document.createElement('canvas');
hrvContainer.appendChild(canvas);
// Create chart
const chart = new Chart(canvas, {
type: 'line',
data: {
labels: dates,
datasets: [
{
label: 'HRV',
data: hrvValues,
borderColor: this.colors.primary,
backgroundColor: 'rgba(74, 111, 165, 0.1)',
fill: true,
yAxisID: 'y'
},
{
label: 'Resting Pulse',
data: pulseValues,
borderColor: this.colors.accent,
backgroundColor: 'rgba(255, 126, 103, 0.1)',
fill: true,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'HRV and Resting Pulse'
}
},
scales: {
y: {
type: 'linear',
position: 'left',
title: {
display: true,
text: 'HRV (ms)'
}
},
y1: {
type: 'linear',
position: 'right',
title: {
display: true,
text: 'Pulse (bpm)'
},
grid: {
drawOnChartArea: false
}
}
}
}
});
// Store chart reference
this.charts.push(chart);
// Create body battery chart if data is available
const bodyBatteryData = metricsData.filter(day =>
day.metrics['Body Battery'] !== undefined
);
if (bodyBatteryData.length > 0) {
// Parse body battery values (format: "Min : X / Max : Y / Avg : Z")
const batteryDates = bodyBatteryData.map(day => day.date);
const batteryValues = bodyBatteryData.map(day => {
const batteryString = day.metrics['Body Battery'];
const avgMatch = batteryString.match(/Avg\s*:\s*(\d+)/);
return avgMatch ? parseInt(avgMatch[1]) : null;
}).filter(val => val !== null);
// Create container for body battery chart
const bbContainer = this.createChartContainer(wrapper);
const bbCanvas = document.createElement('canvas');
bbContainer.appendChild(bbCanvas);
// Create chart
const bbChart = new Chart(bbCanvas, {
type: 'line',
data: {
labels: batteryDates,
datasets: [{
label: 'Body Battery (Average)',
data: batteryValues,
borderColor: this.colors.secondary,
backgroundColor: 'rgba(111, 185, 143, 0.1)',
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Body Battery Trend'
}
},
scales: {
y: {
min: 0,
max: 100,
title: {
display: true,
text: 'Energy Level'
}
}
}
}
});
// Store chart reference
this.charts.push(bbChart);
}
}
/**
* Show a message when no data is available for a chart
* @param {HTMLElement} container - Container element
* @param {string} message - Message to display
*/
showNoDataMessage(container, message) {
const messageElement = document.createElement('div');
messageElement.className = 'no-data-message';
messageElement.textContent = message;
container.appendChild(messageElement);
}
/**
* Clear all charts
*/
clearCharts() {
// Destroy all chart instances
this.charts.forEach(chart => chart.destroy());
this.charts = [];
}
}
// Create a global instance
const chartsManager = new ChartsManager();