/** * TimeSeriesChart Utility * * Provides chart rendering capabilities for time series data with error bounds. * Uses Chart.js to create interactive and static visualizations. * * Usage: * const chart = window.TimeSeriesChart.create(canvas, { * measurements: [...], * xAxisLabel: "Time", * yAxisLabel: "Concentration", * xAxisUnit: "days", * yAxisUnit: "mg/L" * }); * * window.TimeSeriesChart.update(chart, newMeasurements, options); * window.TimeSeriesChart.destroy(chart); */ window.TimeSeriesChart = { // === PUBLIC API === /** * Create an interactive time series chart * * @param {HTMLCanvasElement} canvas - Canvas element to render chart on * @param {Object} options - Chart configuration options * @param {Array} options.measurements - Array of measurement objects with timestamp, value, error, note * @param {string} options.xAxisLabel - Label for x-axis (default: "Time") * @param {string} options.yAxisLabel - Label for y-axis (default: "Value") * @param {string} options.xAxisUnit - Unit for x-axis (default: "") * @param {string} options.yAxisUnit - Unit for y-axis (default: "") * @returns {Chart|null} Chart.js instance or null if creation failed */ create(canvas, options = {}) { if (!this._validateCanvas(canvas)) return null; if (!window.Chart) { console.warn("Chart.js is not loaded"); return null; } const ctx = canvas.getContext("2d"); if (!ctx) return null; const chartData = this._transformData(options.measurements || [], options); if (chartData.datasets.length === 0) { return null; // No data to display } const config = this._buildConfig(chartData, options); return new Chart(ctx, config); }, /** * Update an existing chart with new data * * @param {Chart} chartInstance - Chart.js instance to update * @param {Array} measurements - New measurements array * @param {Object} options - Chart configuration options */ update(chartInstance, measurements, options = {}) { if (!chartInstance) return; const chartData = this._transformData(measurements || [], options); chartInstance.data.datasets = chartData.datasets; chartInstance.options.scales.x.title.text = chartData.xAxisLabel; chartInstance.options.scales.y.title.text = chartData.yAxisLabel; chartInstance.update("none"); }, /** * Destroy chart instance and cleanup * * @param {Chart} chartInstance - Chart.js instance to destroy */ destroy(chartInstance) { if (chartInstance && typeof chartInstance.destroy === "function") { chartInstance.destroy(); } }, // === PRIVATE HELPERS === /** * Transform measurements into Chart.js datasets * @private */ _transformData(measurements, options) { const preparedData = this._prepareData(measurements); if (preparedData.length === 0) { return { datasets: [], xAxisLabel: "Time", yAxisLabel: "Value" }; } const xAxisLabel = options.xAxisLabel || "Time"; const yAxisLabel = options.yAxisLabel || "Value"; const xAxisUnit = options.xAxisUnit || ""; const yAxisUnit = options.yAxisUnit || ""; const datasets = []; // Error bounds datasets FIRST (if errors exist) - renders as background const errorDatasets = this._buildErrorDatasets(preparedData); if (errorDatasets.length > 0) { datasets.push(...errorDatasets); } // Main line dataset LAST - renders on top datasets.push(this._buildMainDataset(preparedData, yAxisLabel)); return { datasets: datasets, xAxisLabel: this._formatAxisLabel(xAxisLabel, xAxisUnit), yAxisLabel: this._formatAxisLabel(yAxisLabel, yAxisUnit), }; }, /** * Prepare and validate measurements data * @private */ _prepareData(measurements) { return measurements .filter( (m) => m.timestamp != null && m.value != null, ) .map((m) => { // Normalize timestamp - handle both numeric and date strings let timestamp; if (typeof m.timestamp === "number") { timestamp = m.timestamp; } else { timestamp = new Date(m.timestamp).getTime(); } return { ...m, timestamp: timestamp, }; }) .sort((a, b) => a.timestamp - b.timestamp); }, /** * Build main line dataset * @private */ _buildMainDataset(validMeasurements, yAxisLabel) { return { label: yAxisLabel, data: validMeasurements.map((m) => ({ x: m.timestamp, y: m.value, error: m.error || null, })), borderColor: "rgb(59, 130, 246)", backgroundColor: "rgba(59, 130, 246, 0.1)", tension: 0.1, pointRadius: 0, // Hide individual points pointHoverRadius: 6, fill: false, }; }, /** * Build error bound datasets (upper and lower) * @private */ _buildErrorDatasets(validMeasurements) { const hasErrors = validMeasurements.some( (m) => m.error !== null && m.error !== undefined && m.error > 0, ); if (!hasErrors) return []; const measurementsWithErrors = validMeasurements.filter( (m) => m.error !== null && m.error !== undefined && m.error > 0, ); return [ // Lower error bound - FIRST (bottom layer) { label: "Error (lower)", data: measurementsWithErrors.map((m) => ({ x: m.timestamp, y: m.value - m.error, })), borderColor: "rgba(59, 130, 246, 0.3)", backgroundColor: "rgba(59, 130, 246, 0.15)", pointRadius: 0, fill: false, tension: 0.1, }, // Upper error bound - SECOND (fill back to lower) { label: "Error (upper)", data: measurementsWithErrors.map((m) => ({ x: m.timestamp, y: m.value + m.error, })), borderColor: "rgba(59, 130, 246, 0.3)", backgroundColor: "rgba(59, 130, 246, 0.15)", pointRadius: 0, fill: "-1", // Fill back to previous dataset (lower bound) tension: 0.1, }, ]; }, /** * Build complete Chart.js configuration * @private */ _buildConfig(chartData, options) { return { type: "line", data: { datasets: chartData.datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: "index", }, plugins: { legend: { display: false, }, tooltip: this._buildTooltipConfig( options.xAxisUnit || "", options.yAxisUnit || "", ), }, scales: this._buildScalesConfig( chartData.xAxisLabel, chartData.yAxisLabel, ), }, }; }, /** * Build tooltip configuration with custom callbacks * @private */ _buildTooltipConfig(xAxisUnit, yAxisUnit) { return { enabled: true, callbacks: { title: (contexts) => { // Show timestamp const context = contexts[0]; if (!context) return "Measurement"; const timestamp = context.parsed.x; return xAxisUnit ? `Time: ${timestamp} ${xAxisUnit}` : `Time: ${timestamp}`; }, label: (context) => { // Show value with unit try { const value = context.parsed.y; if (value === null || value === undefined) { return `${context.dataset.label || "Value"}: N/A`; } const valueStr = yAxisUnit ? `${value} ${yAxisUnit}` : String(value); return `${context.dataset.label || "Value"}: ${valueStr}`; } catch (e) { console.error("Tooltip label error:", e); return `${context.dataset.label || "Value"}: ${context.parsed.y ?? "N/A"}`; } }, afterLabel: (context) => { // Show error information try { const point = context.raw; // Main line is now the last dataset (after error bounds if they exist) const isMainDataset = context.dataset.label && !context.dataset.label.startsWith("Error"); if (!point || !isMainDataset) return null; const lines = []; // Show error if available if ( point.error !== null && point.error !== undefined && point.error > 0 ) { const errorStr = yAxisUnit ? `±${point.error.toFixed(4)} ${yAxisUnit}` : `±${point.error.toFixed(4)}`; lines.push(`Error: ${errorStr}`); } return lines.length > 0 ? lines : null; } catch (e) { console.error("Tooltip afterLabel error:", e); return null; } }, }, }; }, /** * Build scales configuration * @private */ _buildScalesConfig(xAxisLabel, yAxisLabel) { return { x: { type: "linear", title: { display: true, text: xAxisLabel || "Time", }, }, y: { title: { display: true, text: yAxisLabel || "Value", }, }, }; }, /** * Format axis label with unit * @private */ _formatAxisLabel(label, unit) { return unit ? `${label} (${unit})` : label; }, /** * Validate canvas element * @private */ _validateCanvas(canvas) { if (!canvas || !(canvas instanceof HTMLCanvasElement)) { console.warn("Invalid canvas element provided to TimeSeriesChart"); return false; } return true; }, };