Field selectors are now dropdowns populated from the panel's actual
data frames. Each option shows 'fieldName (refId)' so users can
distinguish fields from different queries (A, B, C, etc.).
The selected value uses a 'refId:fieldName' composite key so the
panel can unambiguously resolve the correct field even when multiple
queries return fields with the same name.
Data extraction now searches across ALL series (not just series[0]),
fixing the core bug where selecting a field from query B/C had no
effect because the code only looked at the first data frame.
Field resolution priority:
1. Exact 'refId:fieldName' match (from dropdown selection)
2. Fuzzy match by field name, display name, frame name, or refId
3. For charge field only: fallback to first numeric field ('Auto')
Dropdowns also support allowCustomValue for manual entry and
isClearable to reset.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
824 lines
25 KiB
TypeScript
824 lines
25 KiB
TypeScript
import React, { useId, useMemo } from 'react';
|
||
import { PanelProps, Field, DataFrame } from '@grafana/data';
|
||
import { useTheme2 } from '@grafana/ui';
|
||
import { css, keyframes } from '@emotion/css';
|
||
import { ChargeVizOptions } from '../types';
|
||
|
||
interface Props extends PanelProps<ChargeVizOptions> {}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const toFinite = (v: unknown): number | undefined => {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : undefined;
|
||
};
|
||
|
||
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++) {
|
||
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||
den += (xs[i] - xMean) ** 2;
|
||
}
|
||
return den === 0 ? 0 : num / den;
|
||
}
|
||
|
||
/** Infer battery state from the trend of the last ≤ maxPoints values. */
|
||
function inferStateFromTrend(
|
||
values: number[],
|
||
maxPoints = 50,
|
||
threshold = 0.01,
|
||
): 'charging' | 'discharging' | 'stable' {
|
||
const tail = values.length > maxPoints ? values.slice(-maxPoints) : values;
|
||
if (tail.length < 2) {
|
||
return 'stable';
|
||
}
|
||
|
||
// Simple least-squares slope over the tail window
|
||
const n = tail.length;
|
||
let sumX = 0;
|
||
let sumY = 0;
|
||
let sumXY = 0;
|
||
let sumXX = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
sumX += i;
|
||
sumY += tail[i];
|
||
sumXY += i * tail[i];
|
||
sumXX += i * i;
|
||
}
|
||
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
||
|
||
if (slope > threshold) {
|
||
return 'charging';
|
||
}
|
||
if (slope < -threshold) {
|
||
return 'discharging';
|
||
}
|
||
return 'stable';
|
||
}
|
||
|
||
/** Format minutes into a human-readable duration string. */
|
||
function formatMinutes(mins: number): string {
|
||
if (!Number.isFinite(mins) || mins < 0) {
|
||
return '—';
|
||
}
|
||
if (mins < 1) {
|
||
return '< 1 min';
|
||
}
|
||
const h = Math.floor(mins / 60);
|
||
const m = Math.round(mins % 60);
|
||
if (h === 0) {
|
||
return `${m} min`;
|
||
}
|
||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||
}
|
||
|
||
/**
|
||
* Resolve a field across all data frames using the "refId:fieldName" key
|
||
* format produced by the dropdown, or a plain field name for manual entry.
|
||
* Returns the matching field and its companion time field, or undefined.
|
||
*/
|
||
function resolveFieldByKey(
|
||
series: DataFrame[],
|
||
key: string | undefined,
|
||
): { field: Field; timeField: Field } | undefined {
|
||
if (!key) {
|
||
return undefined;
|
||
}
|
||
|
||
// Try "refId:fieldName" format first
|
||
const colonIdx = key.indexOf(':');
|
||
if (colonIdx > 0) {
|
||
const refId = key.substring(0, colonIdx);
|
||
const fieldName = key.substring(colonIdx + 1);
|
||
|
||
for (const frame of series) {
|
||
if (frame.refId !== refId && frame.name !== refId) {
|
||
continue;
|
||
}
|
||
const timeField = frame.fields.find((f) => f.type === 'time');
|
||
const match = frame.fields.find((f) => f.name === fieldName && f.type !== 'time');
|
||
if (match && timeField) {
|
||
return { field: match, timeField };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: search all frames by field name, display name, frame name, or refId
|
||
const needle = key.toLowerCase().trim();
|
||
for (const frame of series) {
|
||
const timeField = frame.fields.find((f) => f.type === 'time');
|
||
if (!timeField) {
|
||
continue;
|
||
}
|
||
|
||
for (const field of frame.fields) {
|
||
if (field.type === 'time') {
|
||
continue;
|
||
}
|
||
|
||
const candidates = [
|
||
field.name,
|
||
field.config?.displayName,
|
||
field.config?.displayNameFromDS,
|
||
frame.name,
|
||
frame.refId,
|
||
];
|
||
|
||
for (const c of candidates) {
|
||
if (c && c.toLowerCase().trim() === needle) {
|
||
return { field, timeField };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Get the first numeric field from the first series (default fallback).
|
||
*/
|
||
function getFirstNumericField(
|
||
series: DataFrame[],
|
||
): { field: Field; timeField: Field } | undefined {
|
||
for (const frame of series) {
|
||
const timeField = frame.fields.find((f) => f.type === 'time');
|
||
const numField = frame.fields.find((f) => f.type === 'number');
|
||
if (numField && timeField) {
|
||
return { field: numField, timeField };
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SVG sub-components
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface BatteryGfxProps {
|
||
fillLevel: number;
|
||
fillColour: string;
|
||
gradientFill: boolean;
|
||
gradientId: string;
|
||
clipId: string;
|
||
startColour: string;
|
||
endColour: string;
|
||
strokeColour: string;
|
||
borderWidth: number;
|
||
showPercentage: boolean;
|
||
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>
|
||
|
||
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
|
||
|
||
<rect
|
||
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
||
rx={r} ry={r}
|
||
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
||
/>
|
||
|
||
<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>
|
||
|
||
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
|
||
|
||
<rect
|
||
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
||
rx={r} ry={r}
|
||
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
||
/>
|
||
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 chargingAnimation = options.chargingAnimation ?? true;
|
||
const borderWidth = options.borderWidth ?? 3;
|
||
const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2;
|
||
const useRegression = options.useRegressionForRate ?? false;
|
||
const showPercentage = options.showPercentage ?? true;
|
||
const showRate = options.showRate ?? true;
|
||
const showLowHigh = options.showLowHigh ?? true;
|
||
const showStatus = options.showStatus ?? true;
|
||
const enableLoad = options.enableLoad ?? false;
|
||
const enableTimeLeft = options.enableTimeLeft ?? false;
|
||
const enableStateField = options.enableStateField ?? false;
|
||
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
|
||
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Data extraction
|
||
// -----------------------------------------------------------------------
|
||
const extracted = useMemo(() => {
|
||
let cur = 0;
|
||
let r = 0;
|
||
let lo = 0;
|
||
let hi = 0;
|
||
let loadValue: number | undefined;
|
||
let timeLeftValue: number | undefined;
|
||
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
|
||
let inferredState: 'charging' | 'discharging' | 'stable' = 'stable';
|
||
|
||
const allSeries = data.series;
|
||
|
||
if (allSeries.length > 0) {
|
||
// Resolve the charge field: user selection → first numeric field
|
||
const chargeResolved = resolveFieldByKey(allSeries, options.chargeField)
|
||
?? getFirstNumericField(allSeries);
|
||
|
||
if (chargeResolved) {
|
||
const rawValues = Array.from(chargeResolved.field.values);
|
||
const rawTimes = Array.from(chargeResolved.timeField.values);
|
||
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) {
|
||
cur = values[values.length - 1];
|
||
lo = Math.min(...values);
|
||
hi = Math.max(...values);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
inferredState = inferStateFromTrend(values);
|
||
}
|
||
}
|
||
|
||
// Load field — search across all series
|
||
if (enableLoad && options.loadField) {
|
||
const loadResolved = resolveFieldByKey(allSeries, options.loadField);
|
||
if (loadResolved) {
|
||
const arr = Array.from(loadResolved.field.values);
|
||
const last = toFinite(arr[arr.length - 1]);
|
||
if (last !== undefined) {
|
||
loadValue = last;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Time remaining field — search across all series
|
||
if (enableTimeLeft && options.timeLeftField) {
|
||
const tlResolved = resolveFieldByKey(allSeries, options.timeLeftField);
|
||
if (tlResolved) {
|
||
const arr = Array.from(tlResolved.field.values);
|
||
const last = toFinite(arr[arr.length - 1]);
|
||
if (last !== undefined) {
|
||
timeLeftValue = last;
|
||
}
|
||
}
|
||
}
|
||
|
||
// State field — search across all series
|
||
if (enableStateField && options.stateField) {
|
||
const stateResolved = resolveFieldByKey(allSeries, options.stateField);
|
||
if (stateResolved) {
|
||
const arr = Array.from(stateResolved.field.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, timeLeftValue, stateFromField, inferredState };
|
||
}, [
|
||
data.series, useRegression, enableLoad, enableTimeLeft, enableStateField,
|
||
options.chargeField, options.loadField, options.timeLeftField, options.stateField,
|
||
stateChargingValue, stateDischargingValue,
|
||
]);
|
||
|
||
const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Derived visual values
|
||
// -----------------------------------------------------------------------
|
||
const fillLevel = Math.max(0, Math.min(100, currentValue));
|
||
|
||
let fillColour = midColour;
|
||
if (fillLevel < lowThreshold) {
|
||
fillColour = lowColour;
|
||
} else if (fillLevel >= highThreshold) {
|
||
fillColour = highColour;
|
||
}
|
||
|
||
let startColour = fillColour;
|
||
let endColour = fillColour;
|
||
|
||
// Determine charging state: state field → inferred trend from last ≤50 points → stable
|
||
const resolvedState = stateFromField ?? inferredState;
|
||
const isCharging = resolvedState === 'charging';
|
||
const isDischarging = resolvedState === 'discharging';
|
||
|
||
if (gradientFill) {
|
||
if (isCharging) {
|
||
endColour = theme.colors.success.text;
|
||
} else if (isDischarging) {
|
||
startColour = theme.colors.error.text;
|
||
}
|
||
}
|
||
|
||
const animating = chargingAnimation && isCharging;
|
||
|
||
const showAnyStats = showRate || showLowHigh || showStatus;
|
||
const hasLoadExtra = enableLoad && loadValue !== undefined;
|
||
const hasTimeLeftExtra = enableTimeLeft && timeLeftValue !== undefined;
|
||
const hasExtras = hasLoadExtra || hasTimeLeftExtra;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Responsive layout
|
||
// -----------------------------------------------------------------------
|
||
|
||
// Auto-revert horizontal to vertical when the panel is too narrow
|
||
// for horizontal battery + side content to fit comfortably.
|
||
const preferredOrientation = options.orientation ?? 'vertical';
|
||
const effectiveOrientation = preferredOrientation === 'horizontal' && width < 300
|
||
? 'vertical'
|
||
: preferredOrientation;
|
||
|
||
const isCompact = effectiveOrientation === 'vertical' ? width < 140 : height < 100;
|
||
const isTiny = width < 80 || height < 80;
|
||
|
||
const useColumnLayout = effectiveOrientation === 'vertical'
|
||
? width < 240
|
||
: height < 200;
|
||
|
||
const statAreaSize = useColumnLayout
|
||
? (effectiveOrientation === 'vertical' ? height * 0.3 : width * 0.3)
|
||
: (effectiveOrientation === '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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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: 1 1 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
${useColumnLayout ? 'width: 100%;' : 'height: 100%;'}
|
||
overflow: hidden;
|
||
`;
|
||
|
||
const sidePanelStyle = css`
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: ${useColumnLayout ? 'center' : 'flex-start'};
|
||
justify-content: center;
|
||
gap: 6px;
|
||
overflow: hidden;
|
||
max-width: 100%;
|
||
`;
|
||
|
||
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 10px;
|
||
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};
|
||
`;
|
||
|
||
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,
|
||
fillColour,
|
||
gradientFill,
|
||
gradientId,
|
||
clipId,
|
||
startColour,
|
||
endColour,
|
||
strokeColour: theme.colors.text.secondary,
|
||
borderWidth,
|
||
showPercentage: showPercentage && !isTiny,
|
||
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
|
||
// -----------------------------------------------------------------------
|
||
return (
|
||
<div className={containerStyle} data-testid="charge-viz-panel">
|
||
<div className={batteryWrapStyle}>
|
||
{effectiveOrientation === 'vertical'
|
||
? <VerticalBattery {...batteryProps} />
|
||
: <HorizontalBattery {...batteryProps} />
|
||
}
|
||
</div>
|
||
|
||
{(showAnyStats || hasExtras) && !isTiny && (
|
||
<div className={sidePanelStyle}>
|
||
{/* Load gauge card */}
|
||
{hasLoadExtra && !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 */}
|
||
{hasLoadExtra && 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>
|
||
)}
|
||
|
||
{/* Time remaining */}
|
||
{hasTimeLeftExtra && (
|
||
<div className={css`
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
border-radius: 8px;
|
||
background: ${theme.colors.background.secondary};
|
||
border: 1px solid ${theme.colors.border.weak};
|
||
white-space: nowrap;
|
||
font-size: ${baseFontSize}px;
|
||
`}>
|
||
<span style={{ fontSize: baseFontSize * 1.1, lineHeight: 1 }}>⏱</span>
|
||
<div>
|
||
<div style={{ opacity: 0.7, fontSize: baseFontSize * 0.8, lineHeight: 1.2 }}>
|
||
Time remaining
|
||
</div>
|
||
<strong style={{ fontSize: baseFontSize * 1.15 }}>
|
||
{formatMinutes(timeLeftValue!)}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Status badge */}
|
||
{showStatus && !isCompact && (
|
||
<div className={statusBadgeStyle}>
|
||
{isCharging ? '⚡ Charging' : isDischarging ? '🔋 On Battery' : '— Stable'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats */}
|
||
{(showRate || showLowHigh) && (
|
||
<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>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|