/** * Consumption Sparkline Component * SVG-based visualization for meter consumption deltas * Adapted from RSSISparkline pattern */ const ConsumptionSparkline = (function() { 'use strict'; // Default configuration const DEFAULT_CONFIG = { width: 100, height: 28, maxSamples: 20, strokeWidth: 1.5, showGradient: true, barMode: true // Use bars instead of line for consumption }; // Color thresholds for consumption deltas // Green = normal/expected, Yellow = elevated, Red = spike const DELTA_COLORS = { normal: '#22c55e', // Green elevated: '#eab308', // Yellow spike: '#ef4444' // Red }; /** * Classify a delta value relative to the average * @param {number} delta - The delta value * @param {number} avgDelta - Average delta for comparison * @returns {string} - 'normal', 'elevated', or 'spike' */ function classifyDelta(delta, avgDelta) { if (avgDelta === 0 || isNaN(avgDelta)) { return delta === 0 ? 'normal' : 'elevated'; } const ratio = Math.abs(delta) / Math.abs(avgDelta); if (ratio <= 1.5) return 'normal'; if (ratio <= 3) return 'elevated'; return 'spike'; } /** * Get color for a delta value */ function getDeltaColor(delta, avgDelta) { const classification = classifyDelta(delta, avgDelta); return DELTA_COLORS[classification]; } /** * Create sparkline SVG for consumption deltas * @param {Array<{timestamp, delta}>} deltas - Array of delta objects * @param {Object} config - Configuration options * @returns {string} - SVG HTML string */ function createSparklineSvg(deltas, config = {}) { const cfg = { ...DEFAULT_CONFIG, ...config }; const { width, height, strokeWidth, showGradient, barMode } = cfg; if (!deltas || deltas.length < 1) { return createEmptySparkline(width, height); } // Extract just the delta values const values = deltas.map(d => d.delta); // Calculate statistics for color classification const avgDelta = values.reduce((a, b) => a + b, 0) / values.length; const maxDelta = Math.max(...values.map(Math.abs), 1); if (barMode) { return createBarSparkline(values, avgDelta, maxDelta, cfg); } return createLineSparkline(values, avgDelta, maxDelta, cfg); } /** * Create bar-style sparkline (better for discrete readings) */ function createBarSparkline(values, avgDelta, maxDelta, cfg) { const { width, height } = cfg; const barCount = Math.min(values.length, cfg.maxSamples); const displayValues = values.slice(-barCount); const barWidth = Math.max(3, (width / barCount) - 1); const barGap = 1; let bars = ''; displayValues.forEach((val, i) => { const normalizedHeight = (Math.abs(val) / maxDelta) * (height - 4); const barHeight = Math.max(2, normalizedHeight); const x = i * (barWidth + barGap); const y = height - barHeight - 2; const color = getDeltaColor(val, avgDelta); bars += ``; }); return ` ${bars} `; } /** * Create line-style sparkline */ function createLineSparkline(values, avgDelta, maxDelta, cfg) { const { width, height, strokeWidth, showGradient } = cfg; const displayValues = values.slice(-cfg.maxSamples); if (displayValues.length < 2) { return createEmptySparkline(width, height); } // Normalize values to 0-1 range const normalized = displayValues.map(v => Math.abs(v) / maxDelta); // Calculate path const stepX = width / (normalized.length - 1); let pathD = ''; let areaD = ''; const points = []; normalized.forEach((val, i) => { const x = i * stepX; const y = height - (val * (height - 4)) - 2; points.push({ x, y, value: displayValues[i] }); if (i === 0) { pathD = `M${x.toFixed(1)},${y.toFixed(1)}`; areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`; } else { pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`; areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`; } }); areaD += ` L${width},${height} Z`; // Get color based on latest value const latestValue = displayValues[displayValues.length - 1]; const strokeColor = getDeltaColor(latestValue, avgDelta); const gradientId = `consumption-gradient-${Math.random().toString(36).substr(2, 9)}`; let gradientDef = ''; if (showGradient) { gradientDef = ` `; } return ` ${gradientDef} ${showGradient ? `` : ''} `; } /** * Create empty sparkline placeholder */ function createEmptySparkline(width, height) { return ` Collecting... `; } /** * Create sparkline with summary stats * @param {Array} deltas - Delta history * @param {Object} options - Display options * @returns {string} - HTML string */ function createSparklineWithStats(deltas, options = {}) { const svg = createSparklineSvg(deltas, options); if (!deltas || deltas.length < 2) { return `
${svg}
`; } // Calculate trend const recentDeltas = deltas.slice(-5); const avgRecent = recentDeltas.reduce((a, d) => a + d.delta, 0) / recentDeltas.length; const trend = avgRecent > 0 ? 'up' : avgRecent < 0 ? 'down' : 'stable'; const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '↔'; const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#888'; return `
${svg} ${trendIcon}
`; } // Public API return { createSparklineSvg, createEmptySparkline, createSparklineWithStats, classifyDelta, getDeltaColor, DEFAULT_CONFIG, DELTA_COLORS }; })(); // Make globally available window.ConsumptionSparkline = ConsumptionSparkline;