Add field selectors for charge, load, and state metrics
Field Mapping options (new category at top of editor): - Charge field: pick which numeric field drives the battery level (falls back to first number field if unset) - Enable load metric: toggle + field picker for a load/power draw percentage, displayed as a themed semi-circular gauge arc beside the battery (green < 50%, amber 50-80%, red > 80%) - Enable state field: toggle + field picker + user-defined values for charging/discharging so the panel uses an explicit data field rather than computing state from the rate of change Visual integration: - Load gauge renders in a card with the battery, scales responsively, and collapses to a compact text line in small panels - State field drives the status badge, gradient direction, and charging animation (overrides computed rate when enabled) - All new options use showIf for conditional visibility in the editor Also fixed deprecation warnings by replacing .toArray() with Array.from() for Grafana 12+ compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useId, useMemo } from 'react';
|
import React, { useId, useMemo } from 'react';
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps, Field } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
import { css, keyframes } from '@emotion/css';
|
import { css, keyframes } from '@emotion/css';
|
||||||
import { ChargeVizOptions } from '../types';
|
import { ChargeVizOptions } from '../types';
|
||||||
@@ -42,8 +42,23 @@ function slopePercentPerMinute(times: number[], values: number[]): number {
|
|||||||
return den === 0 ? 0 : num / den;
|
return den === 0 ? 0 : num / den;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find a field by configured name, falling back to first field of given type. */
|
||||||
|
function resolveField(
|
||||||
|
fields: Field[],
|
||||||
|
configuredName: string | undefined,
|
||||||
|
fallbackType: string
|
||||||
|
): Field | undefined {
|
||||||
|
if (configuredName) {
|
||||||
|
const match = fields.find((f) => f.name === configuredName);
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields.find((f) => f.type === fallbackType);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sub-components (SVG batteries)
|
// SVG sub-components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface BatteryGfxProps {
|
interface BatteryGfxProps {
|
||||||
@@ -57,7 +72,6 @@ interface BatteryGfxProps {
|
|||||||
strokeColour: string;
|
strokeColour: string;
|
||||||
borderWidth: number;
|
borderWidth: number;
|
||||||
showPercentage: boolean;
|
showPercentage: boolean;
|
||||||
orientation: 'vertical' | 'horizontal';
|
|
||||||
animating: boolean;
|
animating: boolean;
|
||||||
textColour: string;
|
textColour: string;
|
||||||
}
|
}
|
||||||
@@ -95,17 +109,14 @@ function VerticalBattery(props: BatteryGfxProps) {
|
|||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Terminal nub */}
|
|
||||||
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
|
<rect x="38" y="2" width="34" height="12" rx="4" ry="4" fill={strokeColour} />
|
||||||
|
|
||||||
{/* Body outline */}
|
|
||||||
<rect
|
<rect
|
||||||
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
||||||
rx={r} ry={r}
|
rx={r} ry={r}
|
||||||
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fill — clipped to body shape */}
|
|
||||||
<rect
|
<rect
|
||||||
x={bodyX}
|
x={bodyX}
|
||||||
y={bodyY + bodyH - fillH}
|
y={bodyY + bodyH - fillH}
|
||||||
@@ -162,17 +173,14 @@ function HorizontalBattery(props: BatteryGfxProps) {
|
|||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Terminal nub (right side) */}
|
|
||||||
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
|
<rect x={bodyX + bodyW + 2} y="33" width="12" height="34" rx="4" ry="4" fill={strokeColour} />
|
||||||
|
|
||||||
{/* Body outline */}
|
|
||||||
<rect
|
<rect
|
||||||
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
x={bodyX} y={bodyY} width={bodyW} height={bodyH}
|
||||||
rx={r} ry={r}
|
rx={r} ry={r}
|
||||||
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
stroke={strokeColour} strokeWidth={borderWidth} fill="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fill */}
|
|
||||||
<rect
|
<rect
|
||||||
x={bodyX}
|
x={bodyX}
|
||||||
y={bodyY}
|
y={bodyY}
|
||||||
@@ -201,6 +209,81 @@ function HorizontalBattery(props: BatteryGfxProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
// Main panel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -227,26 +310,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
const showRate = options.showRate ?? true;
|
const showRate = options.showRate ?? true;
|
||||||
const showLowHigh = options.showLowHigh ?? true;
|
const showLowHigh = options.showLowHigh ?? true;
|
||||||
const showStatus = options.showStatus ?? true;
|
const showStatus = options.showStatus ?? true;
|
||||||
|
const enableLoad = options.enableLoad ?? false;
|
||||||
const showAnyStats = showRate || showLowHigh || showStatus;
|
const enableStateField = options.enableStateField ?? false;
|
||||||
|
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
|
||||||
|
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Data extraction
|
// Data extraction
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
const { currentValue, rate, low, high } = useMemo(() => {
|
const extracted = useMemo(() => {
|
||||||
let cur = 0;
|
let cur = 0;
|
||||||
let r = 0;
|
let r = 0;
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
let hi = 0;
|
let hi = 0;
|
||||||
|
let loadValue: number | undefined;
|
||||||
|
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
|
||||||
|
|
||||||
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 fields = series.fields;
|
||||||
const timeField = series.fields.find((f) => f.type === 'time');
|
const timeField = fields.find((f) => f.type === 'time');
|
||||||
|
const chargeField = resolveField(fields, options.chargeField, 'number');
|
||||||
|
|
||||||
if (valueField && timeField) {
|
if (chargeField && timeField) {
|
||||||
const rawValues = valueField.values.toArray();
|
const rawValues = Array.from(chargeField.values);
|
||||||
const rawTimes = timeField.values.toArray();
|
const rawTimes = Array.from(timeField.values);
|
||||||
const values: number[] = [];
|
const values: number[] = [];
|
||||||
const times: number[] = [];
|
const times: number[] = [];
|
||||||
|
|
||||||
@@ -275,10 +363,47 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load field
|
||||||
|
if (enableLoad && options.loadField) {
|
||||||
|
const lf = fields.find((f) => f.name === options.loadField);
|
||||||
|
if (lf) {
|
||||||
|
const arr = Array.from(lf.values);
|
||||||
|
const last = toFinite(arr[arr.length - 1]);
|
||||||
|
if (last !== undefined) {
|
||||||
|
loadValue = last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State field
|
||||||
|
if (enableStateField && options.stateField) {
|
||||||
|
const sf = fields.find((f) => f.name === options.stateField);
|
||||||
|
if (sf) {
|
||||||
|
const arr = Array.from(sf.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 };
|
return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField };
|
||||||
}, [data.series, useRegression]);
|
}, [
|
||||||
|
data.series, useRegression, enableLoad, enableStateField,
|
||||||
|
options.chargeField, options.loadField, options.stateField,
|
||||||
|
stateChargingValue, stateDischargingValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { currentValue, rate, low, high, loadValue, stateFromField } = extracted;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Derived visual values
|
// Derived visual values
|
||||||
@@ -294,35 +419,42 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
|
|
||||||
let startColour = fillColour;
|
let startColour = fillColour;
|
||||||
let endColour = fillColour;
|
let endColour = fillColour;
|
||||||
|
|
||||||
|
// Determine charging state: prefer state field, else use computed rate
|
||||||
|
const isCharging = stateFromField ? stateFromField === 'charging' : rate > 0;
|
||||||
|
const isDischarging = stateFromField ? stateFromField === 'discharging' : rate < 0;
|
||||||
|
|
||||||
if (gradientFill) {
|
if (gradientFill) {
|
||||||
if (rate > 0) {
|
if (isCharging) {
|
||||||
endColour = theme.colors.success.text;
|
endColour = theme.colors.success.text;
|
||||||
} else if (rate < 0) {
|
} else if (isDischarging) {
|
||||||
startColour = theme.colors.error.text;
|
startColour = theme.colors.error.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCharging = rate > 0;
|
|
||||||
const isDischarging = rate < 0;
|
|
||||||
const animating = chargingAnimation && isCharging;
|
const animating = chargingAnimation && isCharging;
|
||||||
|
|
||||||
|
const showAnyStats = showRate || showLowHigh || showStatus;
|
||||||
|
const hasExtras = (enableLoad && loadValue !== undefined);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Responsive layout decisions
|
// Responsive layout
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
const isCompact = orientation === 'vertical' ? width < 140 : height < 100;
|
const isCompact = orientation === 'vertical' ? width < 140 : height < 100;
|
||||||
const isTiny = width < 80 || height < 80;
|
const isTiny = width < 80 || height < 80;
|
||||||
|
|
||||||
// Stats go beside the battery or below/beside it depending on space
|
|
||||||
const useColumnLayout = orientation === 'vertical'
|
const useColumnLayout = orientation === 'vertical'
|
||||||
? width < 220
|
? width < 240
|
||||||
: height < 180;
|
: height < 200;
|
||||||
|
|
||||||
// Scale stat font size to available space
|
|
||||||
const statAreaSize = useColumnLayout
|
const statAreaSize = useColumnLayout
|
||||||
? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
|
? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
|
||||||
: (orientation === 'vertical' ? width * 0.5 : height * 0.5);
|
: (orientation === 'vertical' ? width * 0.5 : height * 0.5);
|
||||||
const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8));
|
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
|
// No-data state
|
||||||
if (data.series.length === 0) {
|
if (data.series.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -362,7 +494,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const batteryWrapStyle = css`
|
const batteryWrapStyle = css`
|
||||||
flex: ${showAnyStats && !isTiny ? '1 1 auto' : '1 1 100%'};
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -372,26 +504,33 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const statsStyle = css`
|
const sidePanelStyle = css`
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: ${useColumnLayout ? 'center' : 'flex-start'};
|
align-items: ${useColumnLayout ? 'center' : 'flex-start'};
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
font-size: ${baseFontSize}px;
|
|
||||||
line-height: 1.4;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const statusStyle = css`
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 2px 8px;
|
padding: 2px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: ${baseFontSize * 0.9}px;
|
font-size: ${baseFontSize * 0.9}px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -407,12 +546,17 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
: theme.colors.text.secondary};
|
: theme.colors.text.secondary};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Determine readable text colour for the percentage overlay
|
const loadCardStyle = css`
|
||||||
const textColour = fillLevel > 45 && fillLevel < 85
|
display: flex;
|
||||||
? '#ffffff'
|
flex-direction: column;
|
||||||
: fillLevel <= 45
|
align-items: center;
|
||||||
? theme.colors.text.primary
|
padding: 4px;
|
||||||
: '#ffffff';
|
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 = {
|
const batteryProps: BatteryGfxProps = {
|
||||||
fillLevel,
|
fillLevel,
|
||||||
@@ -425,11 +569,18 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
strokeColour: theme.colors.text.secondary,
|
strokeColour: theme.colors.text.secondary,
|
||||||
borderWidth,
|
borderWidth,
|
||||||
showPercentage: showPercentage && !isTiny,
|
showPercentage: showPercentage && !isTiny,
|
||||||
orientation,
|
|
||||||
animating,
|
animating,
|
||||||
textColour,
|
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
|
// Render
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -442,31 +593,62 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAnyStats && !isTiny && (
|
{(showAnyStats || hasExtras) && !isTiny && (
|
||||||
<div className={statsStyle}>
|
<div className={sidePanelStyle}>
|
||||||
{showRate && (
|
{/* Load gauge card */}
|
||||||
<div>
|
{hasExtras && !isCompact && (
|
||||||
<span style={{ opacity: 0.7, marginRight: 4 }}>Rate</span>
|
<div className={loadCardStyle}>
|
||||||
<strong>{rate.toFixed(dp)} %/min</strong>
|
<LoadGauge
|
||||||
|
value={loadClamped}
|
||||||
|
size={gaugeSize}
|
||||||
|
colour={loadColour}
|
||||||
|
trackColour={theme.colors.border.weak}
|
||||||
|
textColour={theme.colors.text.primary}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showLowHigh && (
|
|
||||||
<>
|
{/* Compact load fallback when gauge doesn't fit */}
|
||||||
<div>
|
{hasExtras && isCompact && (
|
||||||
<span style={{ opacity: 0.7, marginRight: 4 }}>Low</span>
|
<div className={css`
|
||||||
<strong>{low.toFixed(dp)}%</strong>
|
font-size: ${baseFontSize}px;
|
||||||
</div>
|
white-space: nowrap;
|
||||||
<div>
|
`}>
|
||||||
<span style={{ opacity: 0.7, marginRight: 4 }}>High</span>
|
<span style={{ opacity: 0.7, marginRight: 4 }}>Load</span>
|
||||||
<strong>{high.toFixed(dp)}%</strong>
|
<strong style={{ color: loadColour }}>{loadClamped.toFixed(0)}%</strong>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
{showStatus && !isCompact && (
|
{showStatus && !isCompact && (
|
||||||
<div className={statusStyle}>
|
<div className={statusBadgeStyle}>
|
||||||
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
|
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,75 @@ import { ChargeVizOptions } from './types';
|
|||||||
|
|
||||||
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
||||||
(builder) => {
|
(builder) => {
|
||||||
|
// -- Field Mapping category (top) --
|
||||||
|
builder.addFieldNamePicker({
|
||||||
|
path: 'chargeField',
|
||||||
|
name: 'Charge field',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Numeric field that represents battery charge (0–100%). If blank, the first number field is used.',
|
||||||
|
settings: {
|
||||||
|
filter: (f) => f.type === 'number',
|
||||||
|
noFieldsMessage: 'No numeric fields found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addBooleanSwitch({
|
||||||
|
path: 'enableLoad',
|
||||||
|
name: 'Enable load metric',
|
||||||
|
defaultValue: false,
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Show a second metric representing current load / power draw.',
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addFieldNamePicker({
|
||||||
|
path: 'loadField',
|
||||||
|
name: 'Load field',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Numeric field for load percentage (0–100%).',
|
||||||
|
settings: {
|
||||||
|
filter: (f) => f.type === 'number',
|
||||||
|
noFieldsMessage: 'No numeric fields found',
|
||||||
|
},
|
||||||
|
showIf: (opts) => opts.enableLoad === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addBooleanSwitch({
|
||||||
|
path: 'enableStateField',
|
||||||
|
name: 'Enable state field',
|
||||||
|
defaultValue: false,
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Use a field from the data to determine charging / discharging state instead of computing it from the rate.',
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addFieldNamePicker({
|
||||||
|
path: 'stateField',
|
||||||
|
name: 'State field',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'String or numeric field whose value indicates the battery state.',
|
||||||
|
settings: {
|
||||||
|
noFieldsMessage: 'No fields found',
|
||||||
|
},
|
||||||
|
showIf: (opts) => opts.enableStateField === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addTextInput({
|
||||||
|
path: 'stateChargingValue',
|
||||||
|
name: 'Charging value',
|
||||||
|
defaultValue: 'charging',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Value in the state field that means "charging" (case-insensitive).',
|
||||||
|
showIf: (opts) => opts.enableStateField === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addTextInput({
|
||||||
|
path: 'stateDischargingValue',
|
||||||
|
name: 'Discharging value',
|
||||||
|
defaultValue: 'discharging',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Value in the state field that means "discharging" (case-insensitive).',
|
||||||
|
showIf: (opts) => opts.enableStateField === true,
|
||||||
|
});
|
||||||
|
|
||||||
// -- Colours category --
|
// -- Colours category --
|
||||||
builder.addColorPicker({
|
builder.addColorPicker({
|
||||||
path: 'lowColour',
|
path: 'lowColour',
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
export type BatteryOrientation = 'vertical' | 'horizontal';
|
export type BatteryOrientation = 'vertical' | 'horizontal';
|
||||||
|
|
||||||
export interface ChargeVizOptions {
|
export interface ChargeVizOptions {
|
||||||
|
// Field selectors
|
||||||
|
chargeField: string;
|
||||||
|
enableLoad: boolean;
|
||||||
|
loadField: string;
|
||||||
|
enableStateField: boolean;
|
||||||
|
stateField: string;
|
||||||
|
stateChargingValue: string;
|
||||||
|
stateDischargingValue: string;
|
||||||
|
|
||||||
// Colours
|
// Colours
|
||||||
lowColour: string;
|
lowColour: string;
|
||||||
midColour: string;
|
midColour: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user