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
+383
-167
@@ -1,78 +1,248 @@
|
|||||||
// ---------------------------------------------------------------------------
|
import React, { useId, useMemo } from 'react';
|
||||||
// ChargeVizPanel.tsx
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
|
import { css, keyframes } from '@emotion/css';
|
||||||
interface ChargeVizOptions {
|
import { ChargeVizOptions } from '../types';
|
||||||
lowColour: string;
|
|
||||||
midColour: string;
|
|
||||||
highColour: string;
|
|
||||||
gradientFill: boolean;
|
|
||||||
decimalPlaces: number;
|
|
||||||
useRegressionForRate: boolean;
|
|
||||||
hideText: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props extends PanelProps<ChargeVizOptions> {}
|
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);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : undefined;
|
return Number.isFinite(n) ? n : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute least-squares slope (% per minute) for stable trend estimation.
|
|
||||||
function slopePercentPerMinute(times: number[], values: number[]): number {
|
function slopePercentPerMinute(times: number[], values: number[]): number {
|
||||||
const pts: Array<[number, number]> = [];
|
const pts: Array<[number, number]> = [];
|
||||||
for (let i = 0; i < times.length; i++) {
|
for (let i = 0; i < times.length; i++) {
|
||||||
const t = toFinite(times[i]);
|
const t = toFinite(times[i]);
|
||||||
const v = toFinite(values[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 xs = pts.map((p) => (p[0] - pts[0][0]) / 60000);
|
||||||
const ys = pts.map(p => p[1]);
|
const ys = pts.map((p) => p[1]);
|
||||||
const xMean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
const xMean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||||
const yMean = ys.reduce((a, b) => a + b, 0) / ys.length;
|
const yMean = ys.reduce((a, b) => a + b, 0) / ys.length;
|
||||||
|
|
||||||
let num = 0;
|
let num = 0;
|
||||||
let den = 0;
|
let den = 0;
|
||||||
for (let i = 0; i < xs.length; i++) {
|
for (let i = 0; i < xs.length; i++) {
|
||||||
const dx = xs[i] - xMean;
|
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||||||
const dy = ys[i] - yMean;
|
den += (xs[i] - xMean) ** 2;
|
||||||
num += dx * dy;
|
|
||||||
den += dx * dx;
|
|
||||||
}
|
}
|
||||||
return den === 0 ? 0 : num / den;
|
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 lowColour = options.lowColour || '#F44336';
|
||||||
const midColour = options.midColour || '#FF9800';
|
const midColour = options.midColour || '#FF9800';
|
||||||
const highColour = options.highColour || '#4CAF50';
|
const highColour = options.highColour || '#4CAF50';
|
||||||
|
const lowThreshold = options.lowThreshold ?? 30;
|
||||||
|
const highThreshold = options.highThreshold ?? 70;
|
||||||
const gradientFill = options.gradientFill ?? true;
|
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 dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2;
|
||||||
const useRegression = options.useRegressionForRate ?? false;
|
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;
|
const { currentValue, rate, low, high } = useMemo(() => {
|
||||||
let rate = 0;
|
let cur = 0;
|
||||||
let low = 0;
|
let r = 0;
|
||||||
let high = 0;
|
let lo = 0;
|
||||||
|
let hi = 0;
|
||||||
|
|
||||||
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 valueField = series.fields.find((f) => f.type === 'number');
|
||||||
const timeField = series.fields.find(f => f.type === 'time');
|
const timeField = series.fields.find((f) => f.type === 'time');
|
||||||
|
|
||||||
if (valueField && timeField) {
|
if (valueField && timeField) {
|
||||||
const rawValues = valueField.values.toArray();
|
const rawValues = valueField.values.toArray();
|
||||||
@@ -90,167 +260,213 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (values.length > 0) {
|
if (values.length > 0) {
|
||||||
currentValue = values[values.length - 1];
|
cur = values[values.length - 1];
|
||||||
low = Math.min(...values);
|
lo = Math.min(...values);
|
||||||
high = Math.max(...values);
|
hi = Math.max(...values);
|
||||||
|
|
||||||
if (useRegression && values.length >= 2) {
|
if (values.length >= 2) {
|
||||||
rate = slopePercentPerMinute(times, values);
|
if (useRegression) {
|
||||||
} else if (values.length >= 2) {
|
r = slopePercentPerMinute(times, values);
|
||||||
|
} else {
|
||||||
const deltaPercent = values[values.length - 1] - values[0];
|
const deltaPercent = values[values.length - 1] - values[0];
|
||||||
const deltaMinutes = (times[times.length - 1] - times[0]) / 60000;
|
const deltaMinutes = (times[times.length - 1] - times[0]) / 60000;
|
||||||
rate = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0;
|
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));
|
const fillLevel = Math.max(0, Math.min(100, currentValue));
|
||||||
|
|
||||||
// Select colour by thresholds.
|
let fillColour = midColour;
|
||||||
let fillColour = highColour;
|
if (fillLevel < lowThreshold) {
|
||||||
if (fillLevel < 30) fillColour = lowColour;
|
fillColour = lowColour;
|
||||||
else if (fillLevel < 70) fillColour = midColour;
|
} else if (fillLevel >= highThreshold) {
|
||||||
|
fillColour = highColour;
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Gradient setup for charge/discharge effect
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
const gradientId = 'batteryGradient';
|
|
||||||
let gradientDef: React.ReactNode = null;
|
|
||||||
if (gradientFill) {
|
|
||||||
let startColour = fillColour;
|
let startColour = fillColour;
|
||||||
let endColour = fillColour;
|
let endColour = fillColour;
|
||||||
|
if (gradientFill) {
|
||||||
if (rate > 0) {
|
if (rate > 0) {
|
||||||
startColour = fillColour;
|
|
||||||
endColour = theme.colors.success.text;
|
endColour = theme.colors.success.text;
|
||||||
} else if (rate < 0) {
|
} else if (rate < 0) {
|
||||||
startColour = theme.colors.error.text;
|
startColour = theme.colors.error.text;
|
||||||
endColour = fillColour;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gradientDef = (
|
const isCharging = rate > 0;
|
||||||
<defs>
|
const isDischarging = rate < 0;
|
||||||
<linearGradient id={gradientId} x1="0" y1="1" x2="0" y2="0">
|
const animating = chargingAnimation && isCharging;
|
||||||
<stop offset="0%" stopColor={startColour} />
|
|
||||||
<stop offset="100%" stopColor={endColour} />
|
// -----------------------------------------------------------------------
|
||||||
</linearGradient>
|
// Responsive layout decisions
|
||||||
</defs>
|
// -----------------------------------------------------------------------
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div className={containerStyle} data-testid="charge-viz-panel">
|
||||||
style={{
|
<div className={batteryWrapStyle}>
|
||||||
display: 'flex',
|
{orientation === 'vertical'
|
||||||
alignItems: 'center',
|
? <VerticalBattery {...batteryProps} />
|
||||||
justifyContent: 'flex-start',
|
: <HorizontalBattery {...batteryProps} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Optional side text – vertically centred next to battery */}
|
{showAnyStats && !isTiny && (
|
||||||
{!hideText && (
|
<div className={statsStyle}>
|
||||||
<div
|
{showRate && (
|
||||||
style={{
|
<div>
|
||||||
flex: '1 1 auto',
|
<span style={{ opacity: 0.7, marginRight: 4 }}>Rate</span>
|
||||||
display: 'flex',
|
<strong>{rate.toFixed(dp)} %/min</strong>
|
||||||
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>
|
||||||
<div>Low: {low.toFixed(dp)}%</div>
|
)}
|
||||||
<div>High: {high.toFixed(dp)}%</div>
|
{showLowHigh && (
|
||||||
<div style={{ opacity: 0.8 }}>
|
<>
|
||||||
{rate > 0 ? 'Charging ↑' : rate < 0 ? 'Discharging ↓' : 'Stable'}
|
<div>
|
||||||
|
<span style={{ opacity: 0.7, marginRight: 4 }}>Low</span>
|
||||||
|
<strong>{low.toFixed(dp)}%</strong>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 { PanelPlugin } from '@grafana/data';
|
||||||
// Import the component itself.
|
|
||||||
import { ChargeVizPanel } from './components/ChargeVizPanel';
|
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(
|
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
||||||
// The builder lets us declare editor controls (colour pickers, switches, number inputs).
|
|
||||||
(builder) => {
|
(builder) => {
|
||||||
// Colour: low battery
|
// -- Colours category --
|
||||||
builder.addColorPicker({
|
builder.addColorPicker({
|
||||||
path: 'lowColour', // option field name
|
path: 'lowColour',
|
||||||
name: 'Low battery colour', // label in the editor
|
name: 'Low battery colour',
|
||||||
defaultValue: '#F44336', // sensible default (red)
|
defaultValue: '#F44336',
|
||||||
description: 'Colour when the level is under 30%.',
|
category: ['Colours'],
|
||||||
|
description: 'Colour when charge is below the low threshold.',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Colour: medium battery
|
|
||||||
builder.addColorPicker({
|
builder.addColorPicker({
|
||||||
path: 'midColour',
|
path: 'midColour',
|
||||||
name: 'Medium battery colour',
|
name: 'Medium battery colour',
|
||||||
defaultValue: '#FF9800', // amber
|
defaultValue: '#FF9800',
|
||||||
description: 'Colour when the level is between 30% and 70%.',
|
category: ['Colours'],
|
||||||
|
description: 'Colour when charge is between the low and high thresholds.',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Colour: high battery
|
|
||||||
builder.addColorPicker({
|
builder.addColorPicker({
|
||||||
path: 'highColour',
|
path: 'highColour',
|
||||||
name: 'High battery colour',
|
name: 'High battery colour',
|
||||||
defaultValue: '#4CAF50', // green
|
defaultValue: '#4CAF50',
|
||||||
description: 'Colour when the level is above 70%.',
|
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({
|
builder.addBooleanSwitch({
|
||||||
path: 'gradientFill',
|
path: 'gradientFill',
|
||||||
name: 'Enable gradient fill',
|
name: 'Gradient fill',
|
||||||
defaultValue: true,
|
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({
|
builder.addNumberInput({
|
||||||
path: 'decimalPlaces',
|
path: 'decimalPlaces',
|
||||||
name: 'Decimal places',
|
name: 'Decimal places',
|
||||||
defaultValue: 2,
|
defaultValue: 2,
|
||||||
settings: { min: 0, max: 6, integer: true },
|
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({
|
builder.addBooleanSwitch({
|
||||||
path: 'useRegressionForRate',
|
path: 'useRegressionForRate',
|
||||||
name: 'Use regression for rate',
|
name: 'Use regression for rate',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
description:
|
category: ['Data'],
|
||||||
'Compute %/min using more complicated maths.',
|
description: 'Compute %/min using least-squares regression instead of simple first/last difference.',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the builder so Grafana applies the options.
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+26
-5
@@ -1,7 +1,28 @@
|
|||||||
type SeriesSize = 'sm' | 'md' | 'lg';
|
export type BatteryOrientation = 'vertical' | 'horizontal';
|
||||||
|
|
||||||
export interface SimpleOptions {
|
export interface ChargeVizOptions {
|
||||||
text: string;
|
// Colours
|
||||||
showSeriesCount: boolean;
|
lowColour: string;
|
||||||
seriesCountSize: SeriesSize;
|
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');
|
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,
|
panelEditPage,
|
||||||
readProvisionedDataSource,
|
readProvisionedDataSource,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' });
|
const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' });
|
||||||
await panelEditPage.datasource.set(ds.name);
|
await panelEditPage.datasource.set(ds.name);
|
||||||
await panelEditPage.setVisualization('ChargeViz Battery Panel');
|
await panelEditPage.setVisualization('Battery Panel');
|
||||||
await expect(page.getByTestId('simple-panel-circle')).toBeVisible();
|
await expect(page.getByTestId('charge-viz-panel')).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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user