Files
grafana-battery-visualisation/src/components/ChargeVizPanel.tsx
T
Andy DixonandCopilot fb411e6a4d Replace text inputs with dynamic dropdowns, fix cross-series field resolution
Field selectors are now dropdowns populated from the panel's actual
data frames. Each option shows 'fieldName (refId)' so users can
distinguish fields from different queries (A, B, C, etc.).

The selected value uses a 'refId:fieldName' composite key so the
panel can unambiguously resolve the correct field even when multiple
queries return fields with the same name.

Data extraction now searches across ALL series (not just series[0]),
fixing the core bug where selecting a field from query B/C had no
effect because the code only looked at the first data frame.

Field resolution priority:
1. Exact 'refId:fieldName' match (from dropdown selection)
2. Fuzzy match by field name, display name, frame name, or refId
3. For charge field only: fallback to first numeric field ('Auto')

Dropdowns also support allowCustomValue for manual entry and
isClearable to reset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 13:42:45 +01:00

824 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ChargeVizOptions> {}
// ---------------------------------------------------------------------------
// 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 (
<svg viewBox="0 0 110 244" preserveAspectRatio="xMidYMid meet" width="100%" height="100%">
<defs>
{gradientFill && (
<linearGradient id={gradientId} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor={startColour} />
<stop offset="100%" stopColor={endColour} />
</linearGradient>
)}
<clipPath id={clipId}>
<rect x={bodyX} y={bodyY} width={bodyW} height={bodyH} rx={r} ry={r} />
</clipPath>
</defs>
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
<rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/>
<rect
x={bodyX}
y={bodyY + bodyH - fillH}
width={bodyW}
height={fillH}
fill={gradientFill ? `url(#${gradientId})` : fillColour}
clipPath={`url(#${clipId})`}
className={animating ? css`animation: ${pulseKf} 2s ease-in-out infinite;` : undefined}
/>
{showPercentage && (
<text
x={bodyX + bodyW / 2}
y={bodyY + bodyH / 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColour}
fontSize="28"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{fillLevel.toFixed(0)}%
</text>
)}
</svg>
);
}
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 (
<svg viewBox="0 0 248 110" preserveAspectRatio="xMidYMid meet" width="100%" height="100%">
<defs>
{gradientFill && (
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={startColour} />
<stop offset="100%" stopColor={endColour} />
</linearGradient>
)}
<clipPath id={clipId}>
<rect x={bodyX} y={bodyY} width={bodyW} height={bodyH} rx={r} ry={r} />
</clipPath>
</defs>
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
<rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/>
<rect
x={bodyX}
y={bodyY}
width={fillW}
height={bodyH}
fill={gradientFill ? `url(#${gradientId})` : fillColour}
clipPath={`url(#${clipId})`}
className={animating ? css`animation: ${pulseKf} 2s ease-in-out infinite;` : undefined}
/>
{showPercentage && (
<text
x={bodyX + bodyW / 2}
y={bodyY + bodyH / 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColour}
fontSize="28"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{fillLevel.toFixed(0)}%
</text>
)}
</svg>
);
}
// ---------------------------------------------------------------------------
// 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 (
<svg viewBox="0 0 100 100" width={size} height={size}>
<path d={trackPath} fill="none" stroke={trackColour} strokeWidth="8" strokeLinecap="round" />
{valuePath && (
<path d={valuePath} fill="none" stroke={colour} strokeWidth="8" strokeLinecap="round" />
)}
<text
x={cx}
y={cy - 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColour}
fontSize="18"
fontWeight="bold"
>
{clamped.toFixed(0)}%
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
dominantBaseline="central"
fill={textColour}
fontSize="9"
opacity="0.7"
>
LOAD
</text>
</svg>
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
export const ChargeVizPanel: React.FC<Props> = ({ 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 (
<div
className={css`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
color: ${theme.colors.text.secondary};
font-size: 14px;
`}
data-testid="charge-viz-panel"
>
No data
</div>
);
}
// -----------------------------------------------------------------------
// 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 (
<div className={containerStyle} data-testid="charge-viz-panel">
<div className={batteryWrapStyle}>
{effectiveOrientation === 'vertical'
? <VerticalBattery {...batteryProps} />
: <HorizontalBattery {...batteryProps} />
}
</div>
{(showAnyStats || hasExtras) && !isTiny && (
<div className={sidePanelStyle}>
{/* Load gauge card */}
{hasLoadExtra && !isCompact && (
<div className={loadCardStyle}>
<LoadGauge
value={loadClamped}
size={gaugeSize}
colour={loadColour}
trackColour={theme.colors.border.weak}
textColour={theme.colors.text.primary}
/>
</div>
)}
{/* Compact load fallback when gauge doesn't fit */}
{hasLoadExtra && isCompact && (
<div className={css`
font-size: ${baseFontSize}px;
white-space: nowrap;
`}>
<span style={{ opacity: 0.7, marginRight: 4 }}>Load</span>
<strong style={{ color: loadColour }}>{loadClamped.toFixed(0)}%</strong>
</div>
)}
{/* Time remaining */}
{hasTimeLeftExtra && (
<div className={css`
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 8px;
background: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.border.weak};
white-space: nowrap;
font-size: ${baseFontSize}px;
`}>
<span style={{ fontSize: baseFontSize * 1.1, lineHeight: 1 }}>⏱</span>
<div>
<div style={{ opacity: 0.7, fontSize: baseFontSize * 0.8, lineHeight: 1.2 }}>
Time remaining
</div>
<strong style={{ fontSize: baseFontSize * 1.15 }}>
{formatMinutes(timeLeftValue!)}
</strong>
</div>
</div>
)}
{/* Status badge */}
{showStatus && !isCompact && (
<div className={statusBadgeStyle}>
{isCharging ? '⚡ Charging' : isDischarging ? '🔋 On Battery' : '— Stable'}
</div>
)}
{/* Stats */}
{(showRate || showLowHigh) && (
<div className={statsStyle}>
{showRate && (
<div>
<span style={{ opacity: 0.7, marginRight: 4 }}>Rate</span>
<strong>{rate.toFixed(dp)} %/min</strong>
</div>
)}
{showLowHigh && (
<>
<div>
<span style={{ opacity: 0.7, marginRight: 4 }}>Low</span>
<strong>{low.toFixed(dp)}%</strong>
</div>
<div>
<span style={{ opacity: 0.7, marginRight: 4 }}>High</span>
<strong>{high.toFixed(dp)}%</strong>
</div>
</>
)}
</div>
)}
</div>
)}
</div>
);
};