Files
grafana-battery-visualisation/src/components/ChargeVizPanel.tsx
T
2025-10-11 19:08:18 +02:00

259 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// ChargeVizPanel.tsx
// ---------------------------------------------------------------------------
import React 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;
}
interface Props extends PanelProps<ChargeVizOptions> {}
// Utility: safely convert any value to a finite number.
const toFinite = (v: any): 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 (pts.length < 2) return 0;
const xs = pts.map(p => (p[0] - pts[0][0]) / 60000);
const ys = pts.map(p => p[1]);
const xMean = xs.reduce((a, b) => a + b, 0) / xs.length;
const yMean = ys.reduce((a, b) => a + b, 0) / ys.length;
let num = 0;
let den = 0;
for (let i = 0; i < xs.length; i++) {
const dx = xs[i] - xMean;
const dy = ys[i] - yMean;
num += dx * dy;
den += dx * dx;
}
return den === 0 ? 0 : num / den;
}
export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }) => {
const theme = useTheme2();
const lowColour = options.lowColour || '#F44336';
const midColour = options.midColour || '#FF9800';
const highColour = options.highColour || '#4CAF50';
const gradientFill = options.gradientFill ?? true;
const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2;
const useRegression = options.useRegressionForRate ?? false;
const hideText = options.hideText ?? false;
// -----------------------------------------------------------------------
// Data extraction and basic stats
// -----------------------------------------------------------------------
let currentValue = 0;
let rate = 0;
let low = 0;
let high = 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 (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);
}
}
if (values.length > 0) {
currentValue = values[values.length - 1];
low = Math.min(...values);
high = 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;
}
}
}
}
// Clamp the percentage range.
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;
// -----------------------------------------------------------------------
// Gradient setup for charge/discharge effect
// -----------------------------------------------------------------------
const gradientId = 'batteryGradient';
let gradientDef: React.ReactNode = null;
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>
);
}
// The SVG’s internal fill height (for the battery graphic)
const fillHeightPx = (fillLevel / 100) * 220;
// -----------------------------------------------------------------------
// Render layout
// -----------------------------------------------------------------------
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>
{/* 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>
</div>
)}
</div>
);
};