Initial Version

This commit is contained in:
Andy Dixon
2025-10-11 18:36:06 +02:00
parent 5a689b53ff
commit 548b7c89b6
9 changed files with 525 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
// ---------------------------------------------------------------------------
// ChargeVizPanel.tsx
// Responsive Grafana battery visual with fine-tuned spacing and optional text hiding.
// ---------------------------------------------------------------------------
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> {}
const toFinite = (v: any): number | undefined => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
// Computes a least-squares slope (% per minute) to smooth noisy data.
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;
let currentValue = 0;
let rate = 0;
let low = 0;
let high = 0;
// Extract and process first numeric series
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;
}
}
}
}
const fillLevel = Math.max(0, Math.min(100, currentValue));
let fillColour = highColour;
if (fillLevel < 30) fillColour = lowColour;
else if (fillLevel < 70) fillColour = midColour;
// Gradient fill logic
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>
);
}
// -------------------- Dynamic scaling (Option B) ------------------------
const aspectRatio = 0.5;
const panelRatio = height / width;
let batteryWidth: number;
let batteryHeight: number;
if (panelRatio < aspectRatio) {
batteryHeight = height * 0.9;
batteryWidth = batteryHeight / aspectRatio;
} else {
// Use slightly less width when text visible to reduce crowding.
batteryWidth = width * (hideText ? 0.95 : 0.6);
batteryHeight = batteryWidth * aspectRatio;
}
const fillHeightPx = (fillLevel / 100) * 220;
// -------------------- Render ------------------------
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: hideText ? 'center' : 'flex-start',
height: '100%',
width: '100%',
fontFamily: 'Inter, sans-serif',
color: theme.colors.text.primary,
padding: 2, // minimal padding all round
boxSizing: 'border-box',
gap: hideText ? '0px' : '6px', // half the previous gap
}}
>
{/* Battery SVG */}
<svg
style={{
height: `${batteryHeight}px`,
width: `${batteryWidth}px`,
flex: '0 0 auto',
}}
viewBox="0 0 120 240"
preserveAspectRatio="xMidYMid meet"
>
{gradientDef}
{/* Outline */}
<rect
x="5"
y="10"
width="110"
height="220"
rx="8"
ry="8"
stroke={theme.colors.text.secondary}
strokeWidth="3"
fill="none"
/>
{/* Terminal */}
<rect
x="50"
y="0"
width="20"
height="8"
rx="2"
ry="2"
fill={theme.colors.text.secondary}
/>
{/* Fill */}
<rect
x="8"
y={230 - fillHeightPx}
width="104"
height={fillHeightPx}
fill={gradientFill ? `url(#${gradientId})` : fillColour}
/>
{/* Text inside battery */}
<text
x="60"
y="120"
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="28"
fontWeight="bold"
>
{fillLevel.toFixed(0)}%
</text>
</svg>
{/* Stats only if enabled */}
{!hideText && (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flex: '1 1 auto',
minWidth: 120,
overflow: 'hidden', // prevent wrapping overflow
whiteSpace: 'nowrap', // stop premature wrapping
textOverflow: 'ellipsis', // if too narrow, ellipsis instead
lineHeight: '1.4em',
}}
>
<div style={{ fontSize: '1.05em', 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>
);
};