import React, { useId, useMemo } from 'react'; import { PanelProps, Field, DataFrame } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; import { css, keyframes } from '@emotion/css'; import { ChargeVizOptions } from '../types'; interface Props extends PanelProps {} // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const toFinite = (v: unknown): number | undefined => { const n = Number(v); return Number.isFinite(n) ? n : undefined; }; function slopePercentPerMinute(times: number[], values: number[]): number { const pts: Array<[number, number]> = []; for (let i = 0; i < times.length; i++) { const t = toFinite(times[i]); const v = toFinite(values[i]); if (t !== undefined && v !== undefined) { pts.push([t, v]); } } if (pts.length < 2) { return 0; } const xs = pts.map((p) => (p[0] - pts[0][0]) / 60000); const ys = pts.map((p) => p[1]); const xMean = xs.reduce((a, b) => a + b, 0) / xs.length; const yMean = ys.reduce((a, b) => a + b, 0) / ys.length; let num = 0; let den = 0; for (let i = 0; i < xs.length; i++) { num += (xs[i] - xMean) * (ys[i] - yMean); den += (xs[i] - xMean) ** 2; } return den === 0 ? 0 : num / den; } /** Infer battery state from the trend of the last ≤ maxPoints values. */ function inferStateFromTrend( values: number[], maxPoints = 50, threshold = 0.01, ): 'charging' | 'discharging' | 'stable' { const tail = values.length > maxPoints ? values.slice(-maxPoints) : values; if (tail.length < 2) { return 'stable'; } // Simple least-squares slope over the tail window const n = tail.length; let sumX = 0; let sumY = 0; let sumXY = 0; let sumXX = 0; for (let i = 0; i < n; i++) { sumX += i; sumY += tail[i]; sumXY += i * tail[i]; sumXX += i * i; } const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); if (slope > threshold) { return 'charging'; } if (slope < -threshold) { return 'discharging'; } return 'stable'; } /** Format minutes into a human-readable duration string. */ function formatMinutes(mins: number): string { if (!Number.isFinite(mins) || mins < 0) { return '—'; } if (mins < 1) { return '< 1 min'; } const h = Math.floor(mins / 60); const m = Math.round(mins % 60); if (h === 0) { return `${m} min`; } return m > 0 ? `${h}h ${m}m` : `${h}h`; } /** * Resolve a field across all data frames using the "refId:fieldName" key * format produced by the dropdown, or a plain field name for manual entry. * Returns the matching field and its companion time field, or undefined. */ function resolveFieldByKey( series: DataFrame[], key: string | undefined, ): { field: Field; timeField: Field } | undefined { if (!key) { return undefined; } // Try "refId:fieldName" format first const colonIdx = key.indexOf(':'); if (colonIdx > 0) { const refId = key.substring(0, colonIdx); const fieldName = key.substring(colonIdx + 1); for (const frame of series) { if (frame.refId !== refId && frame.name !== refId) { continue; } const timeField = frame.fields.find((f) => f.type === 'time'); const match = frame.fields.find((f) => f.name === fieldName && f.type !== 'time'); if (match && timeField) { return { field: match, timeField }; } } } // Fallback: search all frames by field name, display name, frame name, or refId const needle = key.toLowerCase().trim(); for (const frame of series) { const timeField = frame.fields.find((f) => f.type === 'time'); if (!timeField) { continue; } for (const field of frame.fields) { if (field.type === 'time') { continue; } const candidates = [ field.name, field.config?.displayName, field.config?.displayNameFromDS, frame.name, frame.refId, ]; for (const c of candidates) { if (c && c.toLowerCase().trim() === needle) { return { field, timeField }; } } } } return undefined; } /** * Get the first numeric field from the first series (default fallback). */ function getFirstNumericField( series: DataFrame[], ): { field: Field; timeField: Field } | undefined { for (const frame of series) { const timeField = frame.fields.find((f) => f.type === 'time'); const numField = frame.fields.find((f) => f.type === 'number'); if (numField && timeField) { return { field: numField, timeField }; } } return undefined; } // --------------------------------------------------------------------------- // SVG sub-components // --------------------------------------------------------------------------- interface BatteryGfxProps { fillLevel: number; fillColour: string; gradientFill: boolean; gradientId: string; clipId: string; startColour: string; endColour: string; strokeColour: string; borderWidth: number; showPercentage: boolean; animating: boolean; textColour: string; } const pulseKf = keyframes` 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } `; function VerticalBattery(props: BatteryGfxProps) { const { fillLevel, fillColour, gradientFill, gradientId, clipId, startColour, endColour, strokeColour, borderWidth, showPercentage, animating, textColour, } = props; const bodyX = 5; const bodyY = 14; const bodyW = 100; const bodyH = 220; const r = 8; const fillH = (fillLevel / 100) * bodyH; return ( {gradientFill && ( )} {showPercentage && ( {fillLevel.toFixed(0)}% )} ); } function HorizontalBattery(props: BatteryGfxProps) { const { fillLevel, fillColour, gradientFill, gradientId, clipId, startColour, endColour, strokeColour, borderWidth, showPercentage, animating, textColour, } = props; const bodyX = 5; const bodyY = 5; const bodyW = 220; const bodyH = 100; const r = 8; const fillW = (fillLevel / 100) * bodyW; return ( {gradientFill && ( )} {showPercentage && ( {fillLevel.toFixed(0)}% )} ); } // --------------------------------------------------------------------------- // Load gauge – a semi-circular arc rendered as SVG // --------------------------------------------------------------------------- interface LoadGaugeProps { value: number; // 0–100 size: number; // px diameter colour: string; trackColour: string; textColour: string; } function LoadGauge({ value, size, colour, trackColour, textColour }: LoadGaugeProps) { const clamped = Math.max(0, Math.min(100, value)); const r = 40; const cx = 50; const cy = 50; const startAngle = 135; const totalSweep = 270; const endAngle = startAngle + (clamped / 100) * totalSweep; const toRad = (deg: number) => (deg * Math.PI) / 180; const arcPoint = (angle: number) => ({ x: cx + r * Math.cos(toRad(angle)), y: cy + r * Math.sin(toRad(angle)), }); // Track (background arc) const trackEnd = startAngle + totalSweep; const tStart = arcPoint(startAngle); const tEnd = arcPoint(trackEnd); const trackLargeArc = totalSweep > 180 ? 1 : 0; const trackPath = `M ${tStart.x} ${tStart.y} A ${r} ${r} 0 ${trackLargeArc} 1 ${tEnd.x} ${tEnd.y}`; // Value arc const vStart = arcPoint(startAngle); const vEnd = arcPoint(endAngle); const sweep = (clamped / 100) * totalSweep; const largeArc = sweep > 180 ? 1 : 0; const valuePath = clamped > 0 ? `M ${vStart.x} ${vStart.y} A ${r} ${r} 0 ${largeArc} 1 ${vEnd.x} ${vEnd.y}` : ''; return ( {valuePath && ( )} {clamped.toFixed(0)}% LOAD ); } // --------------------------------------------------------------------------- // Main panel // --------------------------------------------------------------------------- export const ChargeVizPanel: React.FC = ({ data, width, height, options, id }) => { const theme = useTheme2(); const reactId = useId(); const gradientId = `battGrad-${id}-${reactId}`; const clipId = `battClip-${id}-${reactId}`; // Options with defaults const lowColour = options.lowColour || '#F44336'; const midColour = options.midColour || '#FF9800'; const highColour = options.highColour || '#4CAF50'; const lowThreshold = options.lowThreshold ?? 30; const highThreshold = options.highThreshold ?? 70; const gradientFill = options.gradientFill ?? true; const chargingAnimation = options.chargingAnimation ?? true; const borderWidth = options.borderWidth ?? 3; const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2; const useRegression = options.useRegressionForRate ?? false; const showPercentage = options.showPercentage ?? true; const showRate = options.showRate ?? true; const showLowHigh = options.showLowHigh ?? true; const showStatus = options.showStatus ?? true; const enableLoad = options.enableLoad ?? false; const enableTimeLeft = options.enableTimeLeft ?? false; const enableStateField = options.enableStateField ?? false; const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase(); const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase(); // ----------------------------------------------------------------------- // Data extraction // ----------------------------------------------------------------------- const extracted = useMemo(() => { let cur = 0; let r = 0; let lo = 0; let hi = 0; let loadValue: number | undefined; let timeLeftValue: number | undefined; let stateFromField: 'charging' | 'discharging' | 'stable' | undefined; let inferredState: 'charging' | 'discharging' | 'stable' = 'stable'; const allSeries = data.series; if (allSeries.length > 0) { // Resolve the charge field: user selection → first numeric field const chargeResolved = resolveFieldByKey(allSeries, options.chargeField) ?? getFirstNumericField(allSeries); if (chargeResolved) { const rawValues = Array.from(chargeResolved.field.values); const rawTimes = Array.from(chargeResolved.timeField.values); const values: number[] = []; const times: number[] = []; for (let i = 0; i < rawValues.length; i++) { const v = toFinite(rawValues[i]); const t = toFinite(rawTimes[i]); if (v !== undefined && t !== undefined) { values.push(v); times.push(t); } } if (values.length > 0) { cur = values[values.length - 1]; lo = Math.min(...values); hi = Math.max(...values); if (values.length >= 2) { if (useRegression) { r = slopePercentPerMinute(times, values); } else { const deltaPercent = values[values.length - 1] - values[0]; const deltaMinutes = (times[times.length - 1] - times[0]) / 60000; r = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0; } } inferredState = inferStateFromTrend(values); } } // Load field — search across all series if (enableLoad && options.loadField) { const loadResolved = resolveFieldByKey(allSeries, options.loadField); if (loadResolved) { const arr = Array.from(loadResolved.field.values); const last = toFinite(arr[arr.length - 1]); if (last !== undefined) { loadValue = last; } } } // Time remaining field — search across all series if (enableTimeLeft && options.timeLeftField) { const tlResolved = resolveFieldByKey(allSeries, options.timeLeftField); if (tlResolved) { const arr = Array.from(tlResolved.field.values); const last = toFinite(arr[arr.length - 1]); if (last !== undefined) { timeLeftValue = last; } } } // State field — search across all series if (enableStateField && options.stateField) { const stateResolved = resolveFieldByKey(allSeries, options.stateField); if (stateResolved) { const arr = Array.from(stateResolved.field.values); const lastRaw = arr[arr.length - 1]; if (lastRaw !== undefined && lastRaw !== null) { const v = String(lastRaw).toLowerCase().trim(); if (v === stateChargingValue) { stateFromField = 'charging'; } else if (v === stateDischargingValue) { stateFromField = 'discharging'; } else { stateFromField = 'stable'; } } } } } return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, timeLeftValue, stateFromField, inferredState }; }, [ data.series, useRegression, enableLoad, enableTimeLeft, enableStateField, options.chargeField, options.loadField, options.timeLeftField, options.stateField, stateChargingValue, stateDischargingValue, ]); const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted; // ----------------------------------------------------------------------- // Derived visual values // ----------------------------------------------------------------------- const fillLevel = Math.max(0, Math.min(100, currentValue)); let fillColour = midColour; if (fillLevel < lowThreshold) { fillColour = lowColour; } else if (fillLevel >= highThreshold) { fillColour = highColour; } let startColour = fillColour; let endColour = fillColour; // Determine charging state: state field → inferred trend from last ≤50 points → stable const resolvedState = stateFromField ?? inferredState; const isCharging = resolvedState === 'charging'; const isDischarging = resolvedState === 'discharging'; if (gradientFill) { if (isCharging) { endColour = theme.colors.success.text; } else if (isDischarging) { startColour = theme.colors.error.text; } } const animating = chargingAnimation && isCharging; const showAnyStats = showRate || showLowHigh || showStatus; const hasLoadExtra = enableLoad && loadValue !== undefined; const hasTimeLeftExtra = enableTimeLeft && timeLeftValue !== undefined; const hasExtras = hasLoadExtra || hasTimeLeftExtra; // ----------------------------------------------------------------------- // Responsive layout // ----------------------------------------------------------------------- // Auto-revert horizontal to vertical when the panel is too narrow // for horizontal battery + side content to fit comfortably. const preferredOrientation = options.orientation ?? 'vertical'; const effectiveOrientation = preferredOrientation === 'horizontal' && width < 300 ? 'vertical' : preferredOrientation; const isCompact = effectiveOrientation === 'vertical' ? width < 140 : height < 100; const isTiny = width < 80 || height < 80; const useColumnLayout = effectiveOrientation === 'vertical' ? width < 240 : height < 200; const statAreaSize = useColumnLayout ? (effectiveOrientation === 'vertical' ? height * 0.3 : width * 0.3) : (effectiveOrientation === 'vertical' ? width * 0.5 : height * 0.5); const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8)); // Gauge size scales with available panel space const gaugeSize = Math.max(48, Math.min(120, Math.min(width, height) * 0.35)); // No-data state if (data.series.length === 0) { return (
No data
); } // ----------------------------------------------------------------------- // Styles // ----------------------------------------------------------------------- const containerStyle = css` display: flex; flex-direction: ${useColumnLayout ? 'column' : 'row'}; align-items: center; justify-content: center; height: 100%; width: 100%; padding: 4px; box-sizing: border-box; gap: ${useColumnLayout ? '2px' : '8px'}; overflow: hidden; font-family: Inter, Helvetica, Arial, sans-serif; color: ${theme.colors.text.primary}; `; const batteryWrapStyle = css` flex: 1 1 auto; display: flex; align-items: center; justify-content: center; min-width: 0; min-height: 0; ${useColumnLayout ? 'width: 100%;' : 'height: 100%;'} overflow: hidden; `; const sidePanelStyle = css` flex: 0 0 auto; display: flex; flex-direction: column; align-items: ${useColumnLayout ? 'center' : 'flex-start'}; justify-content: center; gap: 6px; overflow: hidden; max-width: 100%; `; const statsStyle = css` display: flex; flex-direction: column; align-items: inherit; gap: 2px; font-size: ${baseFontSize}px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; `; const statusBadgeStyle = css` display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 12px; font-size: ${baseFontSize * 0.9}px; font-weight: 600; background: ${isCharging ? theme.colors.success.transparent : isDischarging ? theme.colors.error.transparent : theme.colors.secondary.transparent}; color: ${isCharging ? theme.colors.success.text : isDischarging ? theme.colors.error.text : theme.colors.text.secondary}; `; const loadCardStyle = css` display: flex; flex-direction: column; align-items: center; padding: 4px; border-radius: 8px; background: ${theme.colors.background.secondary}; border: 1px solid ${theme.colors.border.weak}; `; const textColour = fillLevel > 45 ? '#ffffff' : theme.colors.text.primary; const batteryProps: BatteryGfxProps = { fillLevel, fillColour, gradientFill, gradientId, clipId, startColour, endColour, strokeColour: theme.colors.text.secondary, borderWidth, showPercentage: showPercentage && !isTiny, animating, textColour, }; // Load gauge colour: green if low load, amber if moderate, red if high const loadClamped = loadValue !== undefined ? Math.max(0, Math.min(100, loadValue)) : 0; const loadColour = loadClamped >= 80 ? theme.colors.error.text : loadClamped >= 50 ? theme.colors.warning.text : theme.colors.success.text; // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- return (
{effectiveOrientation === 'vertical' ? : }
{(showAnyStats || hasExtras) && !isTiny && (
{/* Load gauge card */} {hasLoadExtra && !isCompact && (
)} {/* Compact load fallback when gauge doesn't fit */} {hasLoadExtra && isCompact && (
Load {loadClamped.toFixed(0)}%
)} {/* Time remaining */} {hasTimeLeftExtra && (
⏱
Time remaining
{formatMinutes(timeLeftValue!)}
)} {/* Status badge */} {showStatus && !isCompact && (
{isCharging ? '⚡ Charging' : isDischarging ? '🔋 On Battery' : '— Stable'}
)} {/* Stats */} {(showRate || showLowHigh) && (
{showRate && (
Rate {rate.toFixed(dp)} %/min
)} {showLowHigh && ( <>
Low {low.toFixed(dp)}%
High {high.toFixed(dp)}%
)}
)}
)}
); };