701 lines
25 KiB
JavaScript
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();
|