Overhaul battery panel: responsive layout, configurable options, enterprise-level UI
- Responsive layout adapts to any panel size (mobile to widescreen)
- Auto-switches between column/row layout based on dimensions
- Gracefully degrades in tiny panels (hides stats, keeps battery)
- Font sizes scale dynamically to available space
- New configurable options via Grafana panel editor:
- Colours category: low/mid/high battery colours
- Thresholds category: configurable low/high % boundaries (was hardcoded 30/70)
- Appearance category: gradient fill, orientation (vertical/horizontal),
charging animation, border width
- Display category: individual show/hide toggles for percentage, rate,
low/high stats, charging status
- Data category: decimal places, regression toggle
- Fixed bugs:
- Gradient ID now unique per panel instance (fixes multi-panel dashboards)
- Battery fill uses clipPath so it respects rounded corners
- Proper 'No data' empty state
- Horizontal battery orientation for wide/short panels
- Pulse animation when charging (configurable)
- Status badge with themed colours (charging/discharging/stable)
- Moved options interface to shared types.ts
- Updated stale e2e tests to match current component
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Generated
+16746
File diff suppressed because it is too large
Load Diff
+404
-188
@@ -1,256 +1,472 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChargeVizPanel.tsx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import React from 'react';
|
||||
import React, { useId, useMemo } from 'react';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
interface ChargeVizOptions {
|
||||
lowColour: string;
|
||||
midColour: string;
|
||||
highColour: string;
|
||||
gradientFill: boolean;
|
||||
decimalPlaces: number;
|
||||
useRegressionForRate: boolean;
|
||||
hideText: boolean;
|
||||
}
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
import { ChargeVizOptions } from '../types';
|
||||
|
||||
interface Props extends PanelProps<ChargeVizOptions> {}
|
||||
|
||||
// Utility: safely convert any value to a finite number.
|
||||
const toFinite = (v: any): number | undefined => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const toFinite = (v: unknown): number | undefined => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
|
||||
// Compute least-squares slope (% per minute) for stable trend estimation.
|
||||
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 (t !== undefined && v !== undefined) {
|
||||
pts.push([t, v]);
|
||||
}
|
||||
}
|
||||
if (pts.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
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 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++) {
|
||||
const dx = xs[i] - xMean;
|
||||
const dy = ys[i] - yMean;
|
||||
num += dx * dy;
|
||||
den += dx * dx;
|
||||
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||||
den += (xs[i] - xMean) ** 2;
|
||||
}
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
|
||||
export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }) => {
|
||||
const theme = useTheme2();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (SVG batteries)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BatteryGfxProps {
|
||||
fillLevel: number;
|
||||
fillColour: string;
|
||||
gradientFill: boolean;
|
||||
gradientId: string;
|
||||
clipId: string;
|
||||
startColour: string;
|
||||
endColour: string;
|
||||
strokeColour: string;
|
||||
borderWidth: number;
|
||||
showPercentage: boolean;
|
||||
orientation: 'vertical' | 'horizontal';
|
||||
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>
|
||||
|
||||
{/* 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}
|
||||
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>
|
||||
|
||||
{/* 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 orientation = options.orientation ?? 'vertical';
|
||||
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 hideText = options.hideText ?? false;
|
||||
const showPercentage = options.showPercentage ?? true;
|
||||
const showRate = options.showRate ?? true;
|
||||
const showLowHigh = options.showLowHigh ?? true;
|
||||
const showStatus = options.showStatus ?? true;
|
||||
|
||||
const showAnyStats = showRate || showLowHigh || showStatus;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Data extraction and basic stats
|
||||
// Data extraction
|
||||
// -----------------------------------------------------------------------
|
||||
let currentValue = 0;
|
||||
let rate = 0;
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
const { currentValue, rate, low, high } = useMemo(() => {
|
||||
let cur = 0;
|
||||
let r = 0;
|
||||
let lo = 0;
|
||||
let hi = 0;
|
||||
|
||||
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');
|
||||
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');
|
||||
|
||||
if (valueField && timeField) {
|
||||
const rawValues = valueField.values.toArray();
|
||||
const rawTimes = timeField.values.toArray();
|
||||
const values: number[] = [];
|
||||
const times: number[] = [];
|
||||
if (valueField && timeField) {
|
||||
const rawValues = valueField.values.toArray();
|
||||
const rawTimes = timeField.values.toArray();
|
||||
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);
|
||||
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) {
|
||||
currentValue = values[values.length - 1];
|
||||
low = Math.min(...values);
|
||||
high = Math.max(...values);
|
||||
if (values.length > 0) {
|
||||
cur = values[values.length - 1];
|
||||
lo = Math.min(...values);
|
||||
hi = Math.max(...values);
|
||||
|
||||
if (useRegression && values.length >= 2) {
|
||||
rate = slopePercentPerMinute(times, values);
|
||||
} else if (values.length >= 2) {
|
||||
const deltaPercent = values[values.length - 1] - values[0];
|
||||
const deltaMinutes = (times[times.length - 1] - times[0]) / 60000;
|
||||
rate = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the percentage range.
|
||||
return { currentValue: cur, rate: r, low: lo, high: hi };
|
||||
}, [data.series, useRegression]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Derived visual values
|
||||
// -----------------------------------------------------------------------
|
||||
const fillLevel = Math.max(0, Math.min(100, currentValue));
|
||||
|
||||
// Select colour by thresholds.
|
||||
let fillColour = highColour;
|
||||
if (fillLevel < 30) fillColour = lowColour;
|
||||
else if (fillLevel < 70) fillColour = midColour;
|
||||
let fillColour = midColour;
|
||||
if (fillLevel < lowThreshold) {
|
||||
fillColour = lowColour;
|
||||
} else if (fillLevel >= highThreshold) {
|
||||
fillColour = highColour;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Gradient setup for charge/discharge effect
|
||||
// -----------------------------------------------------------------------
|
||||
const gradientId = 'batteryGradient';
|
||||
let gradientDef: React.ReactNode = null;
|
||||
let startColour = fillColour;
|
||||
let endColour = fillColour;
|
||||
if (gradientFill) {
|
||||
let startColour = fillColour;
|
||||
let endColour = fillColour;
|
||||
if (rate > 0) {
|
||||
startColour = fillColour;
|
||||
endColour = theme.colors.success.text;
|
||||
} else if (rate < 0) {
|
||||
startColour = theme.colors.error.text;
|
||||
endColour = fillColour;
|
||||
}
|
||||
}
|
||||
|
||||
gradientDef = (
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor={startColour} />
|
||||
<stop offset="100%" stopColor={endColour} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
const isCharging = rate > 0;
|
||||
const isDischarging = rate < 0;
|
||||
const animating = chargingAnimation && isCharging;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Responsive layout decisions
|
||||
// -----------------------------------------------------------------------
|
||||
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;
|
||||
|
||||
// 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));
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// The SVG’s internal fill height (for the battery graphic)
|
||||
const fillHeightPx = (fillLevel / 100) * 220;
|
||||
// -----------------------------------------------------------------------
|
||||
// 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: ${showAnyStats && !isTiny ? '1 1 auto' : '1 1 100%'};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
${useColumnLayout ? 'width: 100%;' : 'height: 100%;'}
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const statsStyle = 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;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const statusStyle = css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
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};
|
||||
`;
|
||||
|
||||
// Determine readable text colour for the percentage overlay
|
||||
const textColour = fillLevel > 45 && fillLevel < 85
|
||||
? '#ffffff'
|
||||
: fillLevel <= 45
|
||||
? theme.colors.text.primary
|
||||
: '#ffffff';
|
||||
|
||||
const batteryProps: BatteryGfxProps = {
|
||||
fillLevel,
|
||||
fillColour,
|
||||
gradientFill,
|
||||
gradientId,
|
||||
clipId,
|
||||
startColour,
|
||||
endColour,
|
||||
strokeColour: theme.colors.text.secondary,
|
||||
borderWidth,
|
||||
showPercentage: showPercentage && !isTiny,
|
||||
orientation,
|
||||
animating,
|
||||
textColour,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render layout
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: theme.colors.text.primary,
|
||||
padding: 0,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Independent battery element */}
|
||||
<div
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{
|
||||
height: '100%', // fill available panel height
|
||||
width: 'auto', // preserve natural aspect ratio – crucial fix
|
||||
}}
|
||||
viewBox="0 0 110 240"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{gradientDef}
|
||||
|
||||
{/* Battery outline */}
|
||||
<rect
|
||||
x="2"
|
||||
y="10"
|
||||
width="106"
|
||||
height="220"
|
||||
rx="6"
|
||||
ry="6"
|
||||
stroke={theme.colors.text.secondary}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Terminal */}
|
||||
<rect
|
||||
x="45"
|
||||
y="0"
|
||||
width="20"
|
||||
height="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
fill={theme.colors.text.secondary}
|
||||
/>
|
||||
|
||||
{/* Fill area */}
|
||||
<rect
|
||||
x="5"
|
||||
y={230 - fillHeightPx}
|
||||
width="100"
|
||||
height={fillHeightPx}
|
||||
fill={gradientFill ? `url(#${gradientId})` : fillColour}
|
||||
/>
|
||||
|
||||
{/* Percentage text inside the battery */}
|
||||
<text
|
||||
x="55"
|
||||
y="120"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
fontSize="28"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{fillLevel.toFixed(0)}%
|
||||
</text>
|
||||
</svg>
|
||||
<div className={containerStyle} data-testid="charge-viz-panel">
|
||||
<div className={batteryWrapStyle}>
|
||||
{orientation === 'vertical'
|
||||
? <VerticalBattery {...batteryProps} />
|
||||
: <HorizontalBattery {...batteryProps} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Optional side text – vertically centred next to battery */}
|
||||
{!hideText && (
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5em',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '1.1em', fontWeight: 600 }}>
|
||||
Rate: {rate.toFixed(dp)} %/min
|
||||
</div>
|
||||
<div>Low: {low.toFixed(dp)}%</div>
|
||||
<div>High: {high.toFixed(dp)}%</div>
|
||||
<div style={{ opacity: 0.8 }}>
|
||||
{rate > 0 ? 'Charging ↑' : rate < 0 ? 'Discharging ↓' : 'Stable'}
|
||||
</div>
|
||||
{showAnyStats && !isTiny && (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{showStatus && !isCompact && (
|
||||
<div className={statusStyle}>
|
||||
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+105
-48
@@ -1,87 +1,144 @@
|
||||
// Import PanelPlugin to register our React component as a Grafana panel.
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
// Import the component itself.
|
||||
import { ChargeVizPanel } from './components/ChargeVizPanel';
|
||||
import { ChargeVizOptions } from './types';
|
||||
|
||||
/**
|
||||
* Re-declare the options interface here to keep this file self-contained for the editor.
|
||||
* These must match the props used in ChargeVizPanel.
|
||||
*/
|
||||
interface ChargeVizOptions {
|
||||
lowColour: string;
|
||||
midColour: string;
|
||||
highColour: string;
|
||||
gradientFill: boolean;
|
||||
decimalPlaces: number;
|
||||
useRegressionForRate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the plugin instance that Grafana will discover.
|
||||
* We also define the panel editor options so users can configure colours and behaviour.
|
||||
*/
|
||||
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
||||
// The builder lets us declare editor controls (colour pickers, switches, number inputs).
|
||||
(builder) => {
|
||||
// Colour: low battery
|
||||
// -- Colours category --
|
||||
builder.addColorPicker({
|
||||
path: 'lowColour', // option field name
|
||||
name: 'Low battery colour', // label in the editor
|
||||
defaultValue: '#F44336', // sensible default (red)
|
||||
description: 'Colour when the level is under 30%.',
|
||||
path: 'lowColour',
|
||||
name: 'Low battery colour',
|
||||
defaultValue: '#F44336',
|
||||
category: ['Colours'],
|
||||
description: 'Colour when charge is below the low threshold.',
|
||||
});
|
||||
|
||||
// Colour: medium battery
|
||||
builder.addColorPicker({
|
||||
path: 'midColour',
|
||||
name: 'Medium battery colour',
|
||||
defaultValue: '#FF9800', // amber
|
||||
description: 'Colour when the level is between 30% and 70%.',
|
||||
defaultValue: '#FF9800',
|
||||
category: ['Colours'],
|
||||
description: 'Colour when charge is between the low and high thresholds.',
|
||||
});
|
||||
|
||||
// Colour: high battery
|
||||
builder.addColorPicker({
|
||||
path: 'highColour',
|
||||
name: 'High battery colour',
|
||||
defaultValue: '#4CAF50', // green
|
||||
description: 'Colour when the level is above 70%.',
|
||||
defaultValue: '#4CAF50',
|
||||
category: ['Colours'],
|
||||
description: 'Colour when charge is above the high threshold.',
|
||||
});
|
||||
|
||||
// Toggle for gradient fill.
|
||||
// -- Thresholds category --
|
||||
builder.addNumberInput({
|
||||
path: 'lowThreshold',
|
||||
name: 'Low threshold (%)',
|
||||
defaultValue: 30,
|
||||
settings: { min: 0, max: 100, integer: true },
|
||||
category: ['Thresholds'],
|
||||
description: 'Charge level below which the low colour is used.',
|
||||
});
|
||||
|
||||
builder.addNumberInput({
|
||||
path: 'highThreshold',
|
||||
name: 'High threshold (%)',
|
||||
defaultValue: 70,
|
||||
settings: { min: 0, max: 100, integer: true },
|
||||
category: ['Thresholds'],
|
||||
description: 'Charge level above which the high colour is used.',
|
||||
});
|
||||
|
||||
// -- Appearance category --
|
||||
builder.addBooleanSwitch({
|
||||
path: 'gradientFill',
|
||||
name: 'Enable gradient fill',
|
||||
name: 'Gradient fill',
|
||||
defaultValue: true,
|
||||
description: 'Show a subtle gradient that hints charging/discharging.',
|
||||
category: ['Appearance'],
|
||||
description: 'Show a gradient that hints at charging/discharging direction.',
|
||||
});
|
||||
|
||||
// Numeric input: decimal places for numbers shown in the stats.
|
||||
builder.addSelect({
|
||||
path: 'orientation',
|
||||
name: 'Orientation',
|
||||
defaultValue: 'vertical',
|
||||
category: ['Appearance'],
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'vertical', label: 'Vertical' },
|
||||
{ value: 'horizontal', label: 'Horizontal' },
|
||||
],
|
||||
},
|
||||
description: 'Battery orientation. Horizontal works well in wide, short panels.',
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'chargingAnimation',
|
||||
name: 'Charging animation',
|
||||
defaultValue: true,
|
||||
category: ['Appearance'],
|
||||
description: 'Show a pulse animation when the battery is charging.',
|
||||
});
|
||||
|
||||
builder.addNumberInput({
|
||||
path: 'borderWidth',
|
||||
name: 'Border width',
|
||||
defaultValue: 3,
|
||||
settings: { min: 1, max: 8, integer: true },
|
||||
category: ['Appearance'],
|
||||
description: 'Thickness of the battery outline.',
|
||||
});
|
||||
|
||||
// -- Display category --
|
||||
builder.addBooleanSwitch({
|
||||
path: 'showPercentage',
|
||||
name: 'Show percentage',
|
||||
defaultValue: true,
|
||||
category: ['Display'],
|
||||
description: 'Show the percentage text inside the battery.',
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'showRate',
|
||||
name: 'Show charge rate',
|
||||
defaultValue: true,
|
||||
category: ['Display'],
|
||||
description: 'Show the rate of change (%/min).',
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'showLowHigh',
|
||||
name: 'Show low / high',
|
||||
defaultValue: true,
|
||||
category: ['Display'],
|
||||
description: 'Show the minimum and maximum values from the series.',
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'showStatus',
|
||||
name: 'Show charging status',
|
||||
defaultValue: true,
|
||||
category: ['Display'],
|
||||
description: 'Show Charging / Discharging / Stable indicator.',
|
||||
});
|
||||
|
||||
// -- Data category --
|
||||
builder.addNumberInput({
|
||||
path: 'decimalPlaces',
|
||||
name: 'Decimal places',
|
||||
defaultValue: 2,
|
||||
settings: { min: 0, max: 6, integer: true },
|
||||
description: 'How many decimal places to use for rate/low/high.',
|
||||
category: ['Data'],
|
||||
description: 'Decimal places for rate / low / high values.',
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'hideText',
|
||||
name: 'Hide stats text (battery only)',
|
||||
defaultValue: false,
|
||||
description: 'If enabled, hides the rate and high/low text, showing only the battery graphic.',
|
||||
});
|
||||
|
||||
|
||||
// Toggle: use regression for rate instead of simple first/last.
|
||||
builder.addBooleanSwitch({
|
||||
path: 'useRegressionForRate',
|
||||
name: 'Use regression for rate',
|
||||
defaultValue: false,
|
||||
description:
|
||||
'Compute %/min using more complicated maths.',
|
||||
category: ['Data'],
|
||||
description: 'Compute %/min using least-squares regression instead of simple first/last difference.',
|
||||
});
|
||||
|
||||
// Return the builder so Grafana applies the options.
|
||||
return builder;
|
||||
}
|
||||
);
|
||||
|
||||
+26
-5
@@ -1,7 +1,28 @@
|
||||
type SeriesSize = 'sm' | 'md' | 'lg';
|
||||
export type BatteryOrientation = 'vertical' | 'horizontal';
|
||||
|
||||
export interface SimpleOptions {
|
||||
text: string;
|
||||
showSeriesCount: boolean;
|
||||
seriesCountSize: SeriesSize;
|
||||
export interface ChargeVizOptions {
|
||||
// Colours
|
||||
lowColour: string;
|
||||
midColour: string;
|
||||
highColour: string;
|
||||
|
||||
// Thresholds
|
||||
lowThreshold: number;
|
||||
highThreshold: number;
|
||||
|
||||
// Appearance
|
||||
gradientFill: boolean;
|
||||
orientation: BatteryOrientation;
|
||||
chargingAnimation: boolean;
|
||||
borderWidth: number;
|
||||
|
||||
// Data display
|
||||
decimalPlaces: number;
|
||||
useRegressionForRate: boolean;
|
||||
|
||||
// Visibility toggles
|
||||
showPercentage: boolean;
|
||||
showRate: boolean;
|
||||
showLowHigh: boolean;
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
+3
-17
@@ -9,27 +9,13 @@ test('should display "No data" in case panel data is empty', async ({
|
||||
await expect(panelEditPage.panel.locator).toContainText('No data');
|
||||
});
|
||||
|
||||
test('should display circle when data is passed to the panel', async ({
|
||||
test('should display the battery panel when data is passed', async ({
|
||||
panelEditPage,
|
||||
readProvisionedDataSource,
|
||||
page,
|
||||
}) => {
|
||||
const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' });
|
||||
await panelEditPage.datasource.set(ds.name);
|
||||
await panelEditPage.setVisualization('ChargeViz Battery Panel');
|
||||
await expect(page.getByTestId('simple-panel-circle')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display series counter when "Show series counter" option is enabled', async ({
|
||||
gotoPanelEditPage,
|
||||
readProvisionedDashboard,
|
||||
page,
|
||||
}) => {
|
||||
const dashboard = await readProvisionedDashboard({ fileName: 'dashboard.json' });
|
||||
const panelEditPage = await gotoPanelEditPage({ dashboard, id: '1' });
|
||||
const options = panelEditPage.getCustomOptions('ChargeViz Battery Panel');
|
||||
const showSeriesCounter = options.getSwitch('Show series counter');
|
||||
|
||||
await showSeriesCounter.check();
|
||||
await expect(page.getByTestId('simple-panel-series-counter')).toBeVisible();
|
||||
await panelEditPage.setVisualization('Battery Panel');
|
||||
await expect(page.getByTestId('charge-viz-panel')).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user