mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 14:50:00 -07:00
feat: Add meter grouping by device ID with consumption trends
Transform flat scrolling meter list into grouped view showing one card per unique meter with: - Consumption history tracking and delta from previous reading - Trend sparkline visualization (color-coded for normal/elevated/spike) - Consumption rate calculation (units/hour over 30-min window) - Cards update in place instead of creating duplicates - Alert sound only plays for new meters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
235
static/js/components/consumption-sparkline.js
Normal file
235
static/js/components/consumption-sparkline.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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 += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||
width="${barWidth.toFixed(1)}" height="${barHeight.toFixed(1)}"
|
||||
fill="${color}" rx="1" opacity="0.85"/>`;
|
||||
});
|
||||
|
||||
return `
|
||||
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<line x1="0" y1="${height - 2}" x2="${width}" y2="${height - 2}"
|
||||
stroke="#333" stroke-width="1" opacity="0.3"/>
|
||||
${bars}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<defs>
|
||||
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
${gradientDef}
|
||||
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||
r="2.5" fill="${strokeColor}" class="sparkline-dot" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty sparkline placeholder
|
||||
*/
|
||||
function createEmptySparkline(width, height) {
|
||||
return `
|
||||
<svg class="consumption-sparkline-svg consumption-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||
stroke="#444" stroke-width="1" stroke-dasharray="3,3" />
|
||||
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||
fill="#555" font-size="9" font-family="monospace">Collecting...</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<div class="consumption-sparkline-wrapper">${svg}</div>`;
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<div class="consumption-sparkline-wrapper">
|
||||
${svg}
|
||||
<span class="consumption-trend" style="color: ${trendColor}" title="Recent trend">
|
||||
${trendIcon}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createSparklineSvg,
|
||||
createEmptySparkline,
|
||||
createSparklineWithStats,
|
||||
classifyDelta,
|
||||
getDeltaColor,
|
||||
DEFAULT_CONFIG,
|
||||
DELTA_COLORS
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.ConsumptionSparkline = ConsumptionSparkline;
|
||||
Reference in New Issue
Block a user