/** * 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();