Initial commit
This commit is contained in:
+700
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user