/** * Advanced chart methods for the Sport Science Coach * Contains implementations for heatmaps, radar charts, and drill-down functionality */ // Add these methods to the ChartsManager class /** * Create sleep heatmap chart * @param {Array} metricsData - Processed metrics data * @param {HTMLElement} wrapper - Wrapper for the chart containers */ ChartsManager.prototype.createSleepHeatmap = function(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 for heatmap'); return; } // Create container for heatmap const heatmapContainer = this.createChartContainer(wrapper); heatmapContainer.classList.add('interactive'); const canvas = document.createElement('canvas'); heatmapContainer.appendChild(canvas); // Process data for heatmap // Group by day of week and week number const heatmapData = this.processDataForSleepHeatmap(sleepData); // Create heatmap chart const chart = new Chart(canvas, { type: 'matrix', data: { datasets: [{ label: 'Sleep Hours', data: heatmapData, backgroundColor(context) { const value = context.dataset.data[context.dataIndex].v; // Color scale based on sleep hours if (value === null) return 'rgba(0, 0, 0, 0.05)'; return value >= 8 ? 'rgba(46, 204, 113, 0.8)' : value >= 7 ? 'rgba(52, 152, 219, 0.8)' : value >= 6 ? 'rgba(241, 196, 15, 0.8)' : 'rgba(231, 76, 60, 0.8)'; }, borderColor: 'rgba(255, 255, 255, 0.2)', borderWidth: 1, width: 25, height: 25 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { callbacks: { title() { return ''; }, label(context) { const v = context.dataset.data[context.dataIndex]; if (v.v === null) return 'No data'; return [ `Date: ${v.date}`, `Sleep: ${v.v.toFixed(1)} hours` ]; } } }, title: { display: true, text: 'Sleep Pattern Heatmap' }, subtitle: { display: true, text: 'Click for detailed view', padding: { bottom: 10 } }, legend: { display: false } }, scales: { y: { type: 'category', labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], offset: true, ticks: { display: true }, grid: { display: false }, title: { display: true, text: 'Day of Week' } }, x: { type: 'category', labels: this.getWeekLabels(sleepData), offset: true, ticks: { display: true }, grid: { display: false }, title: { display: true, text: 'Week' } } } } }); // Store chart reference this.charts.push(chart); // Add heatmap legend const legend = document.createElement('div'); legend.className = 'heatmap-legend'; const legendItems = [ { label: '< 6 hours', color: 'rgba(231, 76, 60, 0.8)' }, { label: '6-7 hours', color: 'rgba(241, 196, 15, 0.8)' }, { label: '7-8 hours', color: 'rgba(52, 152, 219, 0.8)' }, { label: '8+ hours', color: 'rgba(46, 204, 113, 0.8)' } ]; legendItems.forEach(item => { const legendItem = document.createElement('div'); legendItem.className = 'heatmap-legend-item'; const colorBox = document.createElement('div'); colorBox.className = 'heatmap-legend-color'; colorBox.style.backgroundColor = item.color; const label = document.createElement('span'); label.textContent = item.label; legendItem.appendChild(colorBox); legendItem.appendChild(label); legend.appendChild(legendItem); }); heatmapContainer.appendChild(legend); // Add click event for drill-down canvas.addEventListener('click', (evt) => { const points = chart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, false); if (points.length) { const firstPoint = points[0]; const data = chart.data.datasets[firstPoint.datasetIndex].data[firstPoint.index]; if (data.v !== null) { this.createSleepDetailDrillDown(data.date, heatmapContainer); } } }); }; /** * Process data for sleep heatmap * @param {Array} sleepData - Sleep metrics data * @returns {Array} - Processed data for heatmap */ ChartsManager.prototype.processDataForSleepHeatmap = function(sleepData) { const heatmapData = []; // Get the first date in the dataset const firstDate = new Date(sleepData[0].date); const firstWeek = this.getWeekNumber(firstDate); // Create a map of date to sleep hours const dateToSleepHours = {}; sleepData.forEach(day => { dateToSleepHours[day.date] = day.metrics['Sleep Hours']; }); // Generate all days in the range const startDate = new Date(sleepData[0].date); const endDate = new Date(sleepData[sleepData.length - 1].date); // Add one day to endDate to include it in the range endDate.setDate(endDate.getDate() + 1); for (let d = new Date(startDate); d < endDate; d.setDate(d.getDate() + 1)) { const date = d.toISOString().split('T')[0]; const dayOfWeek = d.getDay(); // 0 = Sunday, 6 = Saturday const weekNumber = this.getWeekNumber(d); const weekIndex = weekNumber - firstWeek; heatmapData.push({ x: weekIndex, y: dayOfWeek, v: dateToSleepHours[date] !== undefined ? dateToSleepHours[date] : null, date: date }); } return heatmapData; }; /** * Get week number for a date * @param {Date} date - Date to get week number for * @returns {number} - Week number */ ChartsManager.prototype.getWeekNumber = function(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); }; /** * Get week labels for heatmap * @param {Array} sleepData - Sleep metrics data * @returns {Array} - Week labels */ ChartsManager.prototype.getWeekLabels = function(sleepData) { const firstDate = new Date(sleepData[0].date); const lastDate = new Date(sleepData[sleepData.length - 1].date); const firstWeek = this.getWeekNumber(firstDate); const lastWeek = this.getWeekNumber(lastDate); const labels = []; for (let i = firstWeek; i <= lastWeek; i++) { labels.push(`Week ${i}`); } return labels; }; /** * Create fitness radar chart * @param {Object} data - Processed health and workout data * @param {HTMLElement} wrapper - Wrapper for the chart containers */ ChartsManager.prototype.createFitnessRadarChart = function(data, wrapper) { // Create container for radar chart const radarContainer = this.createChartContainer(wrapper); radarContainer.classList.add('radar-chart-container'); radarContainer.classList.add('interactive'); const canvas = document.createElement('canvas'); radarContainer.appendChild(canvas); // Process data for radar chart const radarData = this.processDataForRadarChart(data); if (!radarData) { this.showNoDataMessage(radarContainer, 'Insufficient data for fitness radar chart'); return; } // Create radar chart const chart = new Chart(canvas, { type: 'radar', data: { labels: ['Sleep Quality', 'Stress Management', 'Workout Intensity', 'Recovery', 'Consistency'], datasets: [ { label: 'Current Profile', data: radarData.current, backgroundColor: 'rgba(111, 185, 143, 0.2)', borderColor: this.colors.secondary, pointBackgroundColor: this.colors.secondary, pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff', pointHoverBorderColor: this.colors.secondary }, { label: 'Optimal Profile', data: radarData.optimal, backgroundColor: 'rgba(74, 111, 165, 0.2)', borderColor: this.colors.primary, pointBackgroundColor: this.colors.primary, pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff', pointHoverBorderColor: this.colors.primary } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: 'Fitness Balance Radar' }, subtitle: { display: true, text: 'Click for detailed view', padding: { bottom: 10 } }, tooltip: { callbacks: { label: (context) => { const value = context.raw; const percentage = (value * 10).toFixed(0); return `${context.dataset.label}: ${percentage}%`; } } }, datalabels: { formatter: (value) => { return (value * 10).toFixed(0); }, color: '#fff', backgroundColor: function(context) { return context.dataset.borderColor; }, borderRadius: 3, font: { weight: 'bold', size: 10 }, padding: 4 } }, scales: { r: { angleLines: { display: true, color: 'rgba(0, 0, 0, 0.1)' }, suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 2, callback: function(value) { return (value * 10) + '%'; } } } } } }); // Store chart reference this.charts.push(chart); // Add click event for drill-down canvas.addEventListener('click', () => { this.createFitnessRadarDrillDown(data, radarData, radarContainer); }); }; /** * Process data for radar chart * @param {Object} data - Processed health and workout data * @returns {Object|null} - Processed data for radar chart or null if insufficient data */ ChartsManager.prototype.processDataForRadarChart = function(data) { // Check if we have enough data if (!data.metrics || data.metrics.length === 0 || !data.workouts || data.workouts.length === 0) { return null; } // Calculate sleep quality score (0-10) const sleepHours = data.metrics .filter(day => day.metrics['Sleep Hours'] !== undefined) .map(day => day.metrics['Sleep Hours']); if (sleepHours.length === 0) return null; const avgSleepHours = this.calculateAverage(sleepHours); const sleepQualityScore = Math.min(10, Math.max(0, (avgSleepHours - 4) * 1.67)) / 10; // Calculate stress management score (0-10) const stressQualifiers = data.metrics .filter(day => day.metrics['Stress Qualifier'] !== undefined) .map(day => day.metrics['Stress Qualifier']); if (stressQualifiers.length === 0) return null; const stressScores = { 'calm': 10, 'balanced': 7.5, 'stressful': 4, 'very_stressful': 1 }; const avgStressScore = this.calculateAverage(stressQualifiers.map(qualifier => stressScores[qualifier] || 5)) / 10; // Calculate workout intensity score (0-10) const workoutIntensities = data.workouts.map(workout => { const heartRateScore = workout.heartRate.average / 180 * 10; // Normalize to 0-10 return Math.min(10, Math.max(0, heartRateScore)); }); const avgWorkoutIntensity = this.calculateAverage(workoutIntensities) / 10; // Calculate recovery score (0-10) const hrvValues = data.metrics .filter(day => day.metrics['HRV'] !== undefined) .map(day => day.metrics['HRV']); if (hrvValues.length === 0) return null; const avgHRV = this.calculateAverage(hrvValues); const recoveryScore = Math.min(10, Math.max(0, avgHRV / 7)) / 10; // Calculate consistency score (0-10) const totalDays = (new Date(data.metrics[data.metrics.length - 1].date) - new Date(data.metrics[0].date)) / (1000 * 60 * 60 * 24); const workoutFrequency = data.workouts.length / (totalDays / 7); // Workouts per week const consistencyScore = Math.min(10, Math.max(0, workoutFrequency * 2.5)) / 10; return { current: [ sleepQualityScore, avgStressScore, avgWorkoutIntensity, recoveryScore, consistencyScore ], optimal: [0.8, 0.8, 0.7, 0.8, 0.7] // Optimal values for comparison }; }; /** * Create fitness radar drill-down * @param {Object} data - Processed health and workout data * @param {Object} radarData - Processed radar chart data * @param {HTMLElement} container - Container element */ ChartsManager.prototype.createFitnessRadarDrillDown = function(data, radarData, container) { // Check if drill-down container already exists let drillDownContainer = container.querySelector('.drill-down-container'); if (!drillDownContainer) { drillDownContainer = document.createElement('div'); drillDownContainer.className = 'drill-down-container'; container.appendChild(drillDownContainer); } else { drillDownContainer.innerHTML = ''; } // Create header const header = document.createElement('div'); header.className = 'drill-down-header'; const title = document.createElement('h4'); title.className = 'drill-down-title'; title.textContent = 'Fitness Balance Details'; const backButton = document.createElement('button'); backButton.className = 'drill-down-back-button'; backButton.innerHTML = ' Back'; backButton.addEventListener('click', () => { drillDownContainer.classList.add('hidden'); }); header.appendChild(title); header.appendChild(backButton); drillDownContainer.appendChild(header); // Create metrics table const metricsTable = document.createElement('table'); metricsTable.style.width = '100%'; metricsTable.style.borderCollapse = 'collapse'; metricsTable.style.marginTop = '20px'; // Add table header const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); ['Metric', 'Score', 'Details', 'Recommendation'].forEach(text => { const th = document.createElement('th'); th.textContent = text; th.style.padding = '8px'; th.style.textAlign = 'left'; th.style.borderBottom = '1px solid #ddd'; headerRow.appendChild(th); }); thead.appendChild(headerRow); metricsTable.appendChild(thead); // Add table body const tbody = document.createElement('tbody'); // Define metrics details const metricsDetails = [ { name: 'Sleep Quality', score: (radarData.current[0] * 10).toFixed(0) + '%', details: `Average sleep: ${this.calculateAverage(data.metrics.filter(day => day.metrics['Sleep Hours'] !== undefined).map(day => day.metrics['Sleep Hours'])).toFixed(1)} hours`, recommendation: radarData.current[0] < 0.7 ? 'Aim for 7-9 hours of sleep per night' : 'Maintain current sleep schedule' }, { name: 'Stress Management', score: (radarData.current[1] * 10).toFixed(0) + '%', details: this.getStressDistribution(data.metrics), recommendation: radarData.current[1] < 0.7 ? 'Incorporate daily mindfulness or meditation practice' : 'Continue effective stress management techniques' }, { name: 'Workout Intensity', score: (radarData.current[2] * 10).toFixed(0) + '%', details: `Average heart rate: ${this.calculateAverage(data.workouts.map(w => w.heartRate.average)).toFixed(0)} bpm`, recommendation: radarData.current[2] < 0.6 ? 'Consider adding high-intensity interval training' : (radarData.current[2] > 0.8 ? 'Balance high-intensity workouts with recovery sessions' : 'Current intensity level is well-balanced') }, { name: 'Recovery', score: (radarData.current[3] * 10).toFixed(0) + '%', details: `Average HRV: ${this.calculateAverage(data.metrics.filter(day => day.metrics['HRV'] !== undefined).map(day => day.metrics['HRV'])).toFixed(0)} ms`, recommendation: radarData.current[3] < 0.7 ? 'Focus on recovery techniques like stretching and proper nutrition' : 'Maintain current recovery practices' }, { name: 'Consistency', score: (radarData.current[4] * 10).toFixed(0) + '%', details: `${data.workouts.length} workouts over ${Math.ceil((new Date(data.metrics[data.metrics.length - 1].date) - new Date(data.metrics[0].date)) / (1000 * 60 * 60 * 24))} days`, recommendation: radarData.current[4] < 0.6 ? 'Establish a regular workout schedule (3-4 times per week)' : 'Maintain current consistency' } ]; // Add metrics rows metricsDetails.forEach(metric => { const row = document.createElement('tr'); ['name', 'score', 'details', 'recommendation'].forEach(prop => { const cell = document.createElement('td'); cell.textContent = metric[prop]; cell.style.padding = '8px'; cell.style.borderBottom = '1px solid #eee'; row.appendChild(cell); }); tbody.appendChild(row); }); metricsTable.appendChild(tbody); drillDownContainer.appendChild(metricsTable); // Add explanation const explanation = document.createElement('div'); explanation.style.marginTop = '20px'; explanation.style.padding = '15px'; explanation.style.backgroundColor = 'rgba(74, 111, 165, 0.1)'; explanation.style.borderRadius = '6px'; const explanationTitle = document.createElement('h4'); explanationTitle.textContent = 'About This Chart'; explanationTitle.style.marginTop = '0'; const explanationText = document.createElement('p'); explanationText.textContent = 'The Fitness Balance Radar provides a holistic view of your health and fitness profile across five key dimensions. Each dimension is scored from 0-100% based on your data, with higher scores indicating better performance. The "Optimal Profile" represents balanced targets across all dimensions.'; explanation.appendChild(explanationTitle); explanation.appendChild(explanationText); drillDownContainer.appendChild(explanation); }; /** * Get stress distribution text * @param {Array} metricsData - Metrics data * @returns {string} - Stress distribution text */ ChartsManager.prototype.getStressDistribution = function(metricsData) { const stressQualifiers = metricsData .filter(day => day.metrics['Stress Qualifier'] !== undefined) .map(day => day.metrics['Stress Qualifier']); const counts = { 'calm': 0, 'balanced': 0, 'stressful': 0, 'very_stressful': 0 }; stressQualifiers.forEach(qualifier => { counts[qualifier] = (counts[qualifier] || 0) + 1; }); const total = stressQualifiers.length; return `${Math.round(counts.calm / total * 100)}% Calm, ${Math.round(counts.balanced / total * 100)}% Balanced, ${Math.round(counts.stressful / total * 100)}% Stressful, ${Math.round(counts.very_stressful / total * 100)}% Very Stressful`; }; /** * Calculate average of array values * @param {Array} values - Array of numbers * @returns {number} - Average value */ ChartsManager.prototype.calculateAverage = function(values) { if (!values || values.length === 0) return 0; return values.reduce((sum, val) => sum + val, 0) / values.length; }; /** * Calculate sum of array values * @param {Array} values - Array of numbers * @returns {number} - Sum of values */ ChartsManager.prototype.calculateSum = function(values) { if (!values || values.length === 0) return 0; return values.reduce((sum, val) => sum + val, 0); };