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
+230 -48
View File
@@ -1,5 +1,5 @@
import React, { useId, useMemo } from 'react';
import { PanelProps } from '@grafana/data';
import { PanelProps, Field } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { css, keyframes } from '@emotion/css';
import { ChargeVizOptions } from '../types';
@@ -42,8 +42,23 @@ function slopePercentPerMinute(times: number[], values: number[]): number {
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 {
@@ -57,7 +72,6 @@ interface BatteryGfxProps {
strokeColour: string;
borderWidth: number;
showPercentage: boolean;
orientation: 'vertical' | 'horizontal';
animating: boolean;
textColour: string;
}
@@ -95,17 +109,14 @@ function VerticalBattery(props: BatteryGfxProps) {
</clipPath>
</defs>
{/* Terminal nub */}
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
{/* Body outline */}
<rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/>
{/* Fill — clipped to body shape */}
<rect
x={bodyX}
y={bodyY + bodyH - fillH}
@@ -162,17 +173,14 @@ function HorizontalBattery(props: BatteryGfxProps) {
</clipPath>
</defs>
{/* Terminal nub (right side) */}
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
{/* Body outline */}
<rect
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
rx={r} ry={r}
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
/>
{/* Fill */}
<rect
x={bodyX}
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
// ---------------------------------------------------------------------------
@@ -227,26 +310,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const showRate = options.showRate ?? true;
const showLowHigh = options.showLowHigh ?? true;
const showStatus = options.showStatus ?? true;
const showAnyStats = showRate || showLowHigh || showStatus;
const enableLoad = options.enableLoad ?? false;
const enableStateField = options.enableStateField ?? false;
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
// -----------------------------------------------------------------------
// Data extraction
// -----------------------------------------------------------------------
const { currentValue, rate, low, high } = useMemo(() => {
const extracted = useMemo(() => {
let cur = 0;
let r = 0;
let lo = 0;
let hi = 0;
let loadValue: number | undefined;
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
if (data.series.length > 0) {
const series = data.series[0];
const valueField = series.fields.find((f) => f.type === 'number');
const timeField = series.fields.find((f) => f.type === 'time');
const fields = series.fields;
const timeField = fields.find((f) => f.type === 'time');
const chargeField = resolveField(fields, options.chargeField, 'number');
if (valueField && timeField) {
const rawValues = valueField.values.toArray();
const rawTimes = timeField.values.toArray();
if (chargeField && timeField) {
const rawValues = Array.from(chargeField.values);
const rawTimes = Array.from(timeField.values);
const values: 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;
}
}
}
return { currentValue: cur, rate: r, low: lo, high: hi };
}, [data.series, useRegression]);
// 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, loadValue, stateFromField };
}, [
data.series, useRegression, enableLoad, enableStateField,
options.chargeField, options.loadField, options.stateField,
stateChargingValue, stateDischargingValue,
]);
const { currentValue, rate, low, high, loadValue, stateFromField } = extracted;
// -----------------------------------------------------------------------
// Derived visual values
@@ -294,35 +419,42 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let startColour = 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 (rate > 0) {
if (isCharging) {
endColour = theme.colors.success.text;
} else if (rate < 0) {
} else if (isDischarging) {
startColour = theme.colors.error.text;
}
}
const isCharging = rate > 0;
const isDischarging = rate < 0;
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 isTiny = width < 80 || height < 80;
// Stats go beside the battery or below/beside it depending on space
const useColumnLayout = orientation === 'vertical'
? width < 220
: height < 180;
? width < 240
: height < 200;
// Scale stat font size to available space
const statAreaSize = useColumnLayout
? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
: (orientation === '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 (
@@ -362,7 +494,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
`;
const batteryWrapStyle = css`
flex: ${showAnyStats && !isTiny ? '1 1 auto' : '1 1 100%'};
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
@@ -372,26 +504,33 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
overflow: hidden;
`;
const statsStyle = css`
const sidePanelStyle = css`
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: ${useColumnLayout ? 'center' : 'flex-start'};
justify-content: center;
gap: 2px;
font-size: ${baseFontSize}px;
line-height: 1.4;
gap: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
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;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 2px 10px;
border-radius: 12px;
font-size: ${baseFontSize * 0.9}px;
font-weight: 600;
@@ -407,12 +546,17 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
: theme.colors.text.secondary};
`;
// Determine readable text colour for the percentage overlay
const textColour = fillLevel > 45 && fillLevel < 85
? '#ffffff'
: fillLevel <= 45
? theme.colors.text.primary
: '#ffffff';
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,
@@ -425,11 +569,18 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
strokeColour: theme.colors.text.secondary,
borderWidth,
showPercentage: showPercentage && !isTiny,
orientation,
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
// -----------------------------------------------------------------------
@@ -442,7 +593,41 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
</div>
{showAnyStats && !isTiny && (
{(showAnyStats || hasExtras) && !isTiny && (
<div className={sidePanelStyle}>
{/* Load gauge card */}
{hasExtras && !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 */}
{hasExtras && 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>
)}
{/* Status badge */}
{showStatus && !isCompact && (
<div className={statusBadgeStyle}>
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
</div>
)}
{/* Stats */}
{(showRate || showLowHigh) && (
<div className={statsStyle}>
{showRate && (
<div>
@@ -462,9 +647,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
</div>
</>
)}
{showStatus && !isCompact && (
<div className={statusStyle}>
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
</div>
)}
</div>
+69
View File
@@ -4,6 +4,75 @@ import { ChargeVizOptions } from './types';
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
(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 --
builder.addColorPicker({
path: 'lowColour',
+9
View File
@@ -1,6 +1,15 @@
export type BatteryOrientation = 'vertical' | 'horizontal';
export interface ChargeVizOptions {
// Field selectors
chargeField: string;
enableLoad: boolean;
loadField: string;
enableStateField: boolean;
stateField: string;
stateChargingValue: string;
stateDischargingValue: string;
// Colours
lowColour: string;
midColour: string;