Add field selectors for charge, load, and state metrics

Field Mapping options (new category at top of editor):
- Charge field: pick which numeric field drives the battery level
  (falls back to first number field if unset)
- Enable load metric: toggle + field picker for a load/power draw
  percentage, displayed as a themed semi-circular gauge arc beside
  the battery (green < 50%, amber 50-80%, red > 80%)
- Enable state field: toggle + field picker + user-defined values
  for charging/discharging so the panel uses an explicit data field
  rather than computing state from the rate of change

Visual integration:
- Load gauge renders in a card with the battery, scales responsively,
  and collapses to a compact text line in small panels
- State field drives the status badge, gradient direction, and
  charging animation (overrides computed rate when enabled)
- All new options use showIf for conditional visibility in the editor

Also fixed deprecation warnings by replacing .toArray() with
Array.from() for Grafana 12+ compatibility.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Dixon
2026-04-24 13:15:21 +01:00
co-authored by Copilot
parent 942a390248
commit da80da71d1
3 changed files with 322 additions and 62 deletions
+244 -62
View File
@@ -1,5 +1,5 @@
import React, { useId, useMemo } from 'react'; import React, { useId, useMemo } from 'react';
import { PanelProps } from '@grafana/data'; import { PanelProps, Field } from '@grafana/data';
import { useTheme2 } from '@grafana/ui'; import { useTheme2 } from '@grafana/ui';
import { css, keyframes } from '@emotion/css'; import { css, keyframes } from '@emotion/css';
import { ChargeVizOptions } from '../types'; import { ChargeVizOptions } from '../types';
@@ -42,8 +42,23 @@ function slopePercentPerMinute(times: number[], values: number[]): number {
return den === 0 ? 0 : num / den; return den === 0 ? 0 : num / den;
} }
/** Find a field by configured name, falling back to first field of given type. */
function resolveField(
fields: Field[],
configuredName: string | undefined,
fallbackType: string
): Field | undefined {
if (configuredName) {
const match = fields.find((f) => f.name === configuredName);
if (match) {
return match;
}
}
return fields.find((f) => f.type === fallbackType);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sub-components (SVG batteries) // SVG sub-components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface BatteryGfxProps { interface BatteryGfxProps {
@@ -57,7 +72,6 @@ interface BatteryGfxProps {
strokeColour: string; strokeColour: string;
borderWidth: number; borderWidth: number;
showPercentage: boolean; showPercentage: boolean;
orientation: 'vertical' | 'horizontal';
animating: boolean; animating: boolean;
textColour: string; textColour: string;
} }
@@ -95,17 +109,14 @@ function VerticalBattery(props: BatteryGfxProps) {
</clipPath> </clipPath>
</defs> </defs>
{/* Terminal nub */}
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} /> <rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
{/* Body outline */}
<rect <rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH} x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r} rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none" stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/> />
{/* Fill — clipped to body shape */}
<rect <rect
x={bodyX} x={bodyX}
y={bodyY + bodyH - fillH} y={bodyY + bodyH - fillH}
@@ -162,17 +173,14 @@ function HorizontalBattery(props: BatteryGfxProps) {
</clipPath> </clipPath>
</defs> </defs>
{/* Terminal nub (right side) */}
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} /> <rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
{/* Body outline */}
<rect <rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH} x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r} rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none" stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/> />
{/* Fill */}
<rect <rect
x={bodyX} x={bodyX}
y={bodyY} y={bodyY}
@@ -201,6 +209,81 @@ function HorizontalBattery(props: BatteryGfxProps) {
); );
} }
// ---------------------------------------------------------------------------
// 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 // Main panel
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -227,26 +310,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const showRate = options.showRate ?? true; const showRate = options.showRate ?? true;
const showLowHigh = options.showLowHigh ?? true; const showLowHigh = options.showLowHigh ?? true;
const showStatus = options.showStatus ?? true; const showStatus = options.showStatus ?? true;
const enableLoad = options.enableLoad ?? false;
const showAnyStats = showRate || showLowHigh || showStatus; const enableStateField = options.enableStateField ?? false;
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Data extraction // Data extraction
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const { currentValue, rate, low, high } = useMemo(() => { const extracted = useMemo(() => {
let cur = 0; let cur = 0;
let r = 0; let r = 0;
let lo = 0; let lo = 0;
let hi = 0; let hi = 0;
let loadValue: number | undefined;
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
if (data.series.length > 0) { if (data.series.length > 0) {
const series = data.series[0]; const series = data.series[0];
const valueField = series.fields.find((f) => f.type === 'number'); const fields = series.fields;
const timeField = series.fields.find((f) => f.type === 'time'); const timeField = fields.find((f) => f.type === 'time');
const chargeField = resolveField(fields, options.chargeField, 'number');
if (valueField && timeField) { if (chargeField && timeField) {
const rawValues = valueField.values.toArray(); const rawValues = Array.from(chargeField.values);
const rawTimes = timeField.values.toArray(); const rawTimes = Array.from(timeField.values);
const values: number[] = []; const values: number[] = [];
const times: number[] = []; const times: number[] = [];
@@ -275,10 +363,47 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
} }
} }
// Load field
if (enableLoad && options.loadField) {
const lf = fields.find((f) => f.name === options.loadField);
if (lf) {
const arr = Array.from(lf.values);
const last = toFinite(arr[arr.length - 1]);
if (last !== undefined) {
loadValue = last;
}
}
}
// State field
if (enableStateField && options.stateField) {
const sf = fields.find((f) => f.name === options.stateField);
if (sf) {
const arr = Array.from(sf.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 }; return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField };
}, [data.series, useRegression]); }, [
data.series, useRegression, enableLoad, enableStateField,
options.chargeField, options.loadField, options.stateField,
stateChargingValue, stateDischargingValue,
]);
const { currentValue, rate, low, high, loadValue, stateFromField } = extracted;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Derived visual values // Derived visual values
@@ -294,35 +419,42 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let startColour = fillColour; let startColour = fillColour;
let endColour = fillColour; let endColour = fillColour;
// Determine charging state: prefer state field, else use computed rate
const isCharging = stateFromField ? stateFromField === 'charging' : rate > 0;
const isDischarging = stateFromField ? stateFromField === 'discharging' : rate < 0;
if (gradientFill) { if (gradientFill) {
if (rate > 0) { if (isCharging) {
endColour = theme.colors.success.text; endColour = theme.colors.success.text;
} else if (rate < 0) { } else if (isDischarging) {
startColour = theme.colors.error.text; startColour = theme.colors.error.text;
} }
} }
const isCharging = rate > 0;
const isDischarging = rate < 0;
const animating = chargingAnimation && isCharging; const animating = chargingAnimation && isCharging;
const showAnyStats = showRate || showLowHigh || showStatus;
const hasExtras = (enableLoad && loadValue !== undefined);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Responsive layout decisions // Responsive layout
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const isCompact = orientation === 'vertical' ? width < 140 : height < 100; const isCompact = orientation === 'vertical' ? width < 140 : height < 100;
const isTiny = width < 80 || height < 80; const isTiny = width < 80 || height < 80;
// Stats go beside the battery or below/beside it depending on space
const useColumnLayout = orientation === 'vertical' const useColumnLayout = orientation === 'vertical'
? width < 220 ? width < 240
: height < 180; : height < 200;
// Scale stat font size to available space
const statAreaSize = useColumnLayout const statAreaSize = useColumnLayout
? (orientation === 'vertical' ? height * 0.3 : width * 0.3) ? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
: (orientation === 'vertical' ? width * 0.5 : height * 0.5); : (orientation === 'vertical' ? width * 0.5 : height * 0.5);
const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8)); 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 // No-data state
if (data.series.length === 0) { if (data.series.length === 0) {
return ( return (
@@ -362,7 +494,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
`; `;
const batteryWrapStyle = css` const batteryWrapStyle = css`
flex: ${showAnyStats && !isTiny ? '1 1 auto' : '1 1 100%'}; flex: 1 1 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -372,26 +504,33 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
overflow: hidden; overflow: hidden;
`; `;
const statsStyle = css` const sidePanelStyle = css`
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: ${useColumnLayout ? 'center' : 'flex-start'}; align-items: ${useColumnLayout ? 'center' : 'flex-start'};
justify-content: center; justify-content: center;
gap: 2px; gap: 6px;
font-size: ${baseFontSize}px;
line-height: 1.4;
overflow: hidden; overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
`; `;
const statusStyle = css` 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; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 2px 8px; padding: 2px 10px;
border-radius: 12px; border-radius: 12px;
font-size: ${baseFontSize * 0.9}px; font-size: ${baseFontSize * 0.9}px;
font-weight: 600; font-weight: 600;
@@ -407,12 +546,17 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
: theme.colors.text.secondary}; : theme.colors.text.secondary};
`; `;
// Determine readable text colour for the percentage overlay const loadCardStyle = css`
const textColour = fillLevel > 45 && fillLevel < 85 display: flex;
? '#ffffff' flex-direction: column;
: fillLevel <= 45 align-items: center;
? theme.colors.text.primary padding: 4px;
: '#ffffff'; 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 = { const batteryProps: BatteryGfxProps = {
fillLevel, fillLevel,
@@ -425,11 +569,18 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
strokeColour: theme.colors.text.secondary, strokeColour: theme.colors.text.secondary,
borderWidth, borderWidth,
showPercentage: showPercentage && !isTiny, showPercentage: showPercentage && !isTiny,
orientation,
animating, animating,
textColour, 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 // Render
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -442,31 +593,62 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
</div> </div>
{showAnyStats && !isTiny && ( {(showAnyStats || hasExtras) && !isTiny && (
<div className={statsStyle}> <div className={sidePanelStyle}>
{showRate && ( {/* Load gauge card */}
<div> {hasExtras && !isCompact && (
<span style={{ opacity: 0.7, marginRight: 4 }}>Rate</span> <div className={loadCardStyle}>
<strong>{rate.toFixed(dp)} %/min</strong> <LoadGauge
value={loadClamped}
size={gaugeSize}
colour={loadColour}
trackColour={theme.colors.border.weak}
textColour={theme.colors.text.primary}
/>
</div> </div>
)} )}
{showLowHigh && (
<> {/* Compact load fallback when gauge doesn't fit */}
<div> {hasExtras && isCompact && (
<span style={{ opacity: 0.7, marginRight: 4 }}>Low</span> <div className={css`
<strong>{low.toFixed(dp)}%</strong> font-size: ${baseFontSize}px;
</div> white-space: nowrap;
<div> `}>
<span style={{ opacity: 0.7, marginRight: 4 }}>High</span> <span style={{ opacity: 0.7, marginRight: 4 }}>Load</span>
<strong>{high.toFixed(dp)}%</strong> <strong style={{ color: loadColour }}>{loadClamped.toFixed(0)}%</strong>
</div> </div>
</>
)} )}
{/* Status badge */}
{showStatus && !isCompact && ( {showStatus && !isCompact && (
<div className={statusStyle}> <div className={statusBadgeStyle}>
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'} {isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
</div> </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>
)} )}
</div> </div>
+69
View File
@@ -4,6 +4,75 @@ import { ChargeVizOptions } from './types';
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions( export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
(builder) => { (builder) => {
// -- Field Mapping category (top) --
builder.addFieldNamePicker({
path: 'chargeField',
name: 'Charge field',
category: ['Field Mapping'],
description: 'Numeric field that represents battery charge (0–100%). If blank, the first number field is used.',
settings: {
filter: (f) => f.type === 'number',
noFieldsMessage: 'No numeric fields found',
},
});
builder.addBooleanSwitch({
path: 'enableLoad',
name: 'Enable load metric',
defaultValue: false,
category: ['Field Mapping'],
description: 'Show a second metric representing current load / power draw.',
});
builder.addFieldNamePicker({
path: 'loadField',
name: 'Load field',
category: ['Field Mapping'],
description: 'Numeric field for load percentage (0–100%).',
settings: {
filter: (f) => f.type === 'number',
noFieldsMessage: 'No numeric fields found',
},
showIf: (opts) => opts.enableLoad === true,
});
builder.addBooleanSwitch({
path: 'enableStateField',
name: 'Enable state field',
defaultValue: false,
category: ['Field Mapping'],
description: 'Use a field from the data to determine charging / discharging state instead of computing it from the rate.',
});
builder.addFieldNamePicker({
path: 'stateField',
name: 'State field',
category: ['Field Mapping'],
description: 'String or numeric field whose value indicates the battery state.',
settings: {
noFieldsMessage: 'No fields found',
},
showIf: (opts) => opts.enableStateField === true,
});
builder.addTextInput({
path: 'stateChargingValue',
name: 'Charging value',
defaultValue: 'charging',
category: ['Field Mapping'],
description: 'Value in the state field that means "charging" (case-insensitive).',
showIf: (opts) => opts.enableStateField === true,
});
builder.addTextInput({
path: 'stateDischargingValue',
name: 'Discharging value',
defaultValue: 'discharging',
category: ['Field Mapping'],
description: 'Value in the state field that means "discharging" (case-insensitive).',
showIf: (opts) => opts.enableStateField === true,
});
// -- Colours category -- // -- Colours category --
builder.addColorPicker({ builder.addColorPicker({
path: 'lowColour', path: 'lowColour',
+9
View File
@@ -1,6 +1,15 @@
export type BatteryOrientation = 'vertical' | 'horizontal'; export type BatteryOrientation = 'vertical' | 'horizontal';
export interface ChargeVizOptions { export interface ChargeVizOptions {
// Field selectors
chargeField: string;
enableLoad: boolean;
loadField: string;
enableStateField: boolean;
stateField: string;
stateChargingValue: string;
stateDischargingValue: string;
// Colours // Colours
lowColour: string; lowColour: string;
midColour: string; midColour: string;