Files
sport-science-coach/js/charts.js
T
2025-04-27 17:43:41 +02:00

1013 lines
37 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;
}
/**
* Helper: Calculate linear trend (slope, intercept, trend line) for a dataset
* @param {Array<number>} y - Array of y values (e.g., totalSleep)
* @returns {Object} { slope, intercept, trendLine }
*/
getLinearTrend(y) {
const n = y.length;
if (n < 2) return { slope: 0, intercept: y[0] || 0, trendLine: y.map(() => y[0] || 0) };
const x = Array.from({ length: n }, (_, i) => i);
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
const sumXX = x.reduce((acc, xi) => acc + xi * xi, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
const trendLine = x.map(xi => slope * xi + intercept);
return { slope, intercept, trendLine };
}
/**
* 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);
// --- Trend Detection for Sleep Duration ---
const { slope, trendLine } = this.getLinearTrend(totalSleep);
let trendLabel = '';
if (slope > 0.1) {
trendLabel = 'Improving Sleep Duration';
} else if (slope < -0.1) {
trendLabel = 'Declining Sleep Duration';
} else {
trendLabel = 'Stable Sleep Duration';
}
// 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);
// --- Goal Line and Progress ---
let goalDataset = null;
let goalLabelDiv = null;
if (typeof this.sleepGoal === "number" && !isNaN(this.sleepGoal) && this.sleepGoal > 0) {
goalDataset = {
label: `Goal (${this.sleepGoal}h)`,
data: totalSleep.map(() => this.sleepGoal),
borderColor: "#2ecc71",
backgroundColor: "rgba(46, 204, 113, 0.08)",
fill: false,
borderDash: [2, 6],
pointRadius: 0,
tension: 0,
order: 0
};
}
// Compose datasets for trend chart
const trendDatasets = [
{
label: 'Sleep Duration',
data: totalSleep,
borderColor: this.colors.sleep.total,
backgroundColor: 'rgba(74, 111, 165, 0.1)',
fill: true,
tension: 0.3
},
{
label: 'Trend',
data: trendLine,
borderColor: '#ff7e67',
backgroundColor: 'rgba(255, 126, 103, 0.08)',
fill: false,
borderDash: [8, 4],
pointRadius: 0,
tension: 0
}
];
if (goalDataset) trendDatasets.push(goalDataset);
const trendChart = new Chart(trendCanvas, {
type: 'line',
data: {
labels: dates,
datasets: trendDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Sleep Duration Trend'
},
legend: {
display: true
}
},
scales: {
y: {
title: {
display: true,
text: 'Hours'
},
min: Math.max(0, Math.min(...totalSleep) - 1),
max: Math.max(...totalSleep) + 1
}
}
}
});
// Add trend label below the chart
const trendLabelDiv = document.createElement('div');
trendLabelDiv.className = 'trend-label';
trendLabelDiv.style.margin = '8px 0 0 0';
trendLabelDiv.style.fontWeight = 'bold';
trendLabelDiv.style.color = slope > 0.1 ? '#2ecc71' : (slope < -0.1 ? '#e74c3c' : '#3498db');
trendLabelDiv.textContent = trendLabel;
trendContainer.appendChild(trendLabelDiv);
// Add goal progress below the trend label if goal is set
if (goalDataset) {
const nightsMet = totalSleep.filter(h => h >= this.sleepGoal).length;
const percentMet = Math.round((nightsMet / totalSleep.length) * 100);
const goalLabelDiv = document.createElement('div');
goalLabelDiv.className = 'goal-label';
goalLabelDiv.style.margin = '4px 0 16px 0';
goalLabelDiv.style.fontWeight = 'bold';
goalLabelDiv.style.color = '#2ecc71';
goalLabelDiv.textContent = `Goal met on ${nightsMet} of ${totalSleep.length} nights (${percentMet}%)`;
trendContainer.appendChild(goalLabelDiv);
}
// 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;
}
});
// --- Trendline and Goal for Stress Level Timeline ---
const { slope: stressSlope, trendLine: stressTrendLine } = this.getLinearTrend(stressValues);
// Default goal: 2 ("balanced")
const stressGoal = 2;
const goalDataset = {
label: 'Goal (Balanced)',
data: stressValues.map(() => stressGoal),
borderColor: "#2ecc71",
backgroundColor: "rgba(46, 204, 113, 0.08)",
fill: false,
borderDash: [2, 6],
pointRadius: 0,
tension: 0,
order: 0
};
// Compose datasets for timeline chart
const timelineDatasets = [
{
label: 'Stress Level',
data: stressValues,
borderColor: this.colors.accent,
backgroundColor: 'rgba(255, 126, 103, 0.1)',
fill: true,
stepped: true
},
{
label: 'Trend',
data: stressTrendLine,
borderColor: '#ff7e67',
backgroundColor: 'rgba(255, 126, 103, 0.08)',
fill: false,
borderDash: [8, 4],
pointRadius: 0,
tension: 0
},
goalDataset
];
const timelineChart = new Chart(timelineCanvas, {
type: 'line',
data: {
labels: dates,
datasets: timelineDatasets
},
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;
}
}
},
legend: {
display: true
}
},
scales: {
y: {
min: 0.5,
max: 4.5,
title: {
display: true,
text: 'Stress Level'
},
ticks: {
callback: (value) => {
switch(value) {
case 1: return 'Calm';
case 2: return 'Balanced';
case 3: return 'Stressful';
case 4: return 'Very Stressful';
default: return '';
}
}
}
}
}
}
});
// Add trend label below the chart
const trendLabelDiv = document.createElement('div');
trendLabelDiv.className = 'trend-label';
trendLabelDiv.style.margin = '8px 0 0 0';
trendLabelDiv.style.fontWeight = 'bold';
trendLabelDiv.style.color = stressSlope < -0.1 ? '#2ecc71' : (stressSlope > 0.1 ? '#e74c3c' : '#3498db');
trendLabelDiv.textContent =
stressSlope < -0.1
? 'Improving Stress Levels'
: (stressSlope > 0.1 ? 'Declining Stress Levels' : 'Stable Stress Levels');
timelineContainer.appendChild(trendLabelDiv);
// Add goal progress below the trend label
const daysMet = stressValues.filter(v => v <= stressGoal).length;
const percentMet = Math.round((daysMet / stressValues.length) * 100);
const goalLabelDiv = document.createElement('div');
goalLabelDiv.className = 'goal-label';
goalLabelDiv.style.margin = '4px 0 16px 0';
goalLabelDiv.style.fontWeight = 'bold';
goalLabelDiv.style.color = '#2ecc71';
goalLabelDiv.textContent = `Goal met on ${daysMet} of ${stressValues.length} days (${percentMet}%)`;
timelineContainer.appendChild(goalLabelDiv);
// 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']);
// --- Trendline and Goal for HRV and Resting Pulse ---
const { slope: hrvSlope, trendLine: hrvTrendLine } = this.getLinearTrend(hrvValues);
const { slope: pulseSlope, trendLine: pulseTrendLine } = this.getLinearTrend(pulseValues);
// Default goals
const hrvGoal = 50;
const pulseGoal = 60;
const hrvGoalDataset = {
label: 'Goal (HRV 50ms)',
data: hrvValues.map(() => hrvGoal),
borderColor: "#2ecc71",
backgroundColor: "rgba(46, 204, 113, 0.08)",
fill: false,
borderDash: [2, 6],
pointRadius: 0,
tension: 0,
yAxisID: 'y',
order: 0
};
const pulseGoalDataset = {
label: 'Goal (Pulse 60bpm)',
data: pulseValues.map(() => pulseGoal),
borderColor: "#3498db",
backgroundColor: "rgba(52, 152, 219, 0.08)",
fill: false,
borderDash: [2, 6],
pointRadius: 0,
tension: 0,
yAxisID: 'y1',
order: 0
};
// Compose datasets for HRV/Pulse chart
const hrvPulseDatasets = [
{
label: 'HRV',
data: hrvValues,
borderColor: this.colors.primary,
backgroundColor: 'rgba(74, 111, 165, 0.1)',
fill: true,
yAxisID: 'y'
},
{
label: 'HRV Trend',
data: hrvTrendLine,
borderColor: '#ff7e67',
backgroundColor: 'rgba(255, 126, 103, 0.08)',
fill: false,
borderDash: [8, 4],
pointRadius: 0,
tension: 0,
yAxisID: 'y'
},
hrvGoalDataset,
{
label: 'Resting Pulse',
data: pulseValues,
borderColor: this.colors.accent,
backgroundColor: 'rgba(255, 126, 103, 0.1)',
fill: true,
yAxisID: 'y1'
},
{
label: 'Pulse Trend',
data: pulseTrendLine,
borderColor: '#8e44ad',
backgroundColor: 'rgba(142, 68, 173, 0.08)',
fill: false,
borderDash: [8, 4],
pointRadius: 0,
tension: 0,
yAxisID: 'y1'
},
pulseGoalDataset
];
// 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: hrvPulseDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'HRV and Resting Pulse'
},
legend: {
display: true
}
},
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
}
}
}
}
});
// Add trend and goal progress labels below the chart
// HRV
const hrvTrendLabelDiv = document.createElement('div');
hrvTrendLabelDiv.className = 'trend-label';
hrvTrendLabelDiv.style.margin = '8px 0 0 0';
hrvTrendLabelDiv.style.fontWeight = 'bold';
hrvTrendLabelDiv.style.color = hrvSlope > 0.1 ? '#2ecc71' : (hrvSlope < -0.1 ? '#e74c3c' : '#3498db');
hrvTrendLabelDiv.textContent =
hrvSlope > 0.1
? 'Improving HRV'
: (hrvSlope < -0.1 ? 'Declining HRV' : 'Stable HRV');
hrvContainer.appendChild(hrvTrendLabelDiv);
const hrvDaysMet = hrvValues.filter(v => v >= hrvGoal).length;
const hrvPercentMet = Math.round((hrvDaysMet / hrvValues.length) * 100);
const hrvGoalLabelDiv = document.createElement('div');
hrvGoalLabelDiv.className = 'goal-label';
hrvGoalLabelDiv.style.margin = '4px 0 0 0';
hrvGoalLabelDiv.style.fontWeight = 'bold';
hrvGoalLabelDiv.style.color = '#2ecc71';
hrvGoalLabelDiv.textContent = `HRV Goal met on ${hrvDaysMet} of ${hrvValues.length} days (${hrvPercentMet}%)`;
hrvContainer.appendChild(hrvGoalLabelDiv);
// Pulse
const pulseTrendLabelDiv = document.createElement('div');
pulseTrendLabelDiv.className = 'trend-label';
pulseTrendLabelDiv.style.margin = '8px 0 0 0';
pulseTrendLabelDiv.style.fontWeight = 'bold';
pulseTrendLabelDiv.style.color = pulseSlope < -0.1 ? '#2ecc71' : (pulseSlope > 0.1 ? '#e74c3c' : '#3498db');
pulseTrendLabelDiv.textContent =
pulseSlope < -0.1
? 'Improving Resting Pulse'
: (pulseSlope > 0.1 ? 'Declining Resting Pulse' : 'Stable Resting Pulse');
hrvContainer.appendChild(pulseTrendLabelDiv);
const pulseDaysMet = pulseValues.filter(v => v <= pulseGoal).length;
const pulsePercentMet = Math.round((pulseDaysMet / pulseValues.length) * 100);
const pulseGoalLabelDiv = document.createElement('div');
pulseGoalLabelDiv.className = 'goal-label';
pulseGoalLabelDiv.style.margin = '4px 0 16px 0';
pulseGoalLabelDiv.style.fontWeight = 'bold';
pulseGoalLabelDiv.style.color = '#2ecc71';
pulseGoalLabelDiv.textContent = `Pulse Goal met on ${pulseDaysMet} of ${pulseValues.length} days (${pulsePercentMet}%)`;
hrvContainer.appendChild(pulseGoalLabelDiv);
// 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);
// --- Trendline and Goal for Body Battery ---
const { slope: bbSlope, trendLine: bbTrendLine } = this.getLinearTrend(batteryValues);
const bbGoal = 80;
const bbGoalDataset = {
label: 'Goal (80)',
data: batteryValues.map(() => bbGoal),
borderColor: "#2ecc71",
backgroundColor: "rgba(46, 204, 113, 0.08)",
fill: false,
borderDash: [2, 6],
pointRadius: 0,
tension: 0,
order: 0
};
// Compose datasets for Body Battery chart
const bbDatasets = [
{
label: 'Body Battery (Average)',
data: batteryValues,
borderColor: this.colors.secondary,
backgroundColor: 'rgba(111, 185, 143, 0.1)',
fill: true
},
{
label: 'Trend',
data: bbTrendLine,
borderColor: '#ff7e67',
backgroundColor: 'rgba(255, 126, 103, 0.08)',
fill: false,
borderDash: [8, 4],
pointRadius: 0,
tension: 0
},
bbGoalDataset
];
// 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: bbDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Body Battery Trend'
},
legend: {
display: true
}
},
scales: {
y: {
min: 0,
max: 100,
title: {
display: true,
text: 'Energy Level'
}
}
}
}
});
// Add trend and goal progress labels below the chart
const bbTrendLabelDiv = document.createElement('div');
bbTrendLabelDiv.className = 'trend-label';
bbTrendLabelDiv.style.margin = '8px 0 0 0';
bbTrendLabelDiv.style.fontWeight = 'bold';
bbTrendLabelDiv.style.color = bbSlope > 0.1 ? '#2ecc71' : (bbSlope < -0.1 ? '#e74c3c' : '#3498db');
bbTrendLabelDiv.textContent =
bbSlope > 0.1
? 'Improving Body Battery'
: (bbSlope < -0.1 ? 'Declining Body Battery' : 'Stable Body Battery');
bbContainer.appendChild(bbTrendLabelDiv);
const bbDaysMet = batteryValues.filter(v => v >= bbGoal).length;
const bbPercentMet = Math.round((bbDaysMet / batteryValues.length) * 100);
const bbGoalLabelDiv = document.createElement('div');
bbGoalLabelDiv.className = 'goal-label';
bbGoalLabelDiv.style.margin = '4px 0 16px 0';
bbGoalLabelDiv.style.fontWeight = 'bold';
bbGoalLabelDiv.style.color = '#2ecc71';
bbGoalLabelDiv.textContent = `Goal met on ${bbDaysMet} of ${batteryValues.length} days (${bbPercentMet}%)`;
bbContainer.appendChild(bbGoalLabelDiv);
// 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();