diff --git a/src/components/ChargeVizPanel.tsx b/src/components/ChargeVizPanel.tsx index 728107a..4631f00 100644 --- a/src/components/ChargeVizPanel.tsx +++ b/src/components/ChargeVizPanel.tsx @@ -76,6 +76,22 @@ function inferStateFromTrend( 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`; +} + /** Find a field by configured name, falling back to first field of given type. */ function resolveField( fields: Field[], @@ -335,7 +351,6 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, const lowThreshold = options.lowThreshold ?? 30; const highThreshold = options.highThreshold ?? 70; 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; @@ -345,6 +360,7 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, 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(); @@ -358,6 +374,7 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, 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'; @@ -414,6 +431,18 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, } } + // Time remaining field + if (enableTimeLeft && options.timeLeftField) { + const tf = fields.find((f) => f.name === options.timeLeftField); + if (tf) { + const arr = Array.from(tf.values); + const last = toFinite(arr[arr.length - 1]); + if (last !== undefined) { + timeLeftValue = last; + } + } + } + // State field if (enableStateField && options.stateField) { const sf = fields.find((f) => f.name === options.stateField); @@ -434,14 +463,14 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, } } - return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField, inferredState }; + return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, timeLeftValue, stateFromField, inferredState }; }, [ - data.series, useRegression, enableLoad, enableStateField, - options.chargeField, options.loadField, options.stateField, + data.series, useRegression, enableLoad, enableTimeLeft, enableStateField, + options.chargeField, options.loadField, options.timeLeftField, options.stateField, stateChargingValue, stateDischargingValue, ]); - const { currentValue, rate, low, high, loadValue, stateFromField, inferredState } = extracted; + const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted; // ----------------------------------------------------------------------- // Derived visual values @@ -474,21 +503,31 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, const animating = chargingAnimation && isCharging; const showAnyStats = showRate || showLowHigh || showStatus; - const hasExtras = (enableLoad && loadValue !== undefined); + const hasLoadExtra = enableLoad && loadValue !== undefined; + const hasTimeLeftExtra = enableTimeLeft && timeLeftValue !== undefined; + const hasExtras = hasLoadExtra || hasTimeLeftExtra; // ----------------------------------------------------------------------- // Responsive layout // ----------------------------------------------------------------------- - const isCompact = orientation === 'vertical' ? width < 140 : height < 100; + + // 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 = orientation === 'vertical' + const useColumnLayout = effectiveOrientation === 'vertical' ? width < 240 : height < 200; const statAreaSize = useColumnLayout - ? (orientation === 'vertical' ? height * 0.3 : width * 0.3) - : (orientation === 'vertical' ? width * 0.5 : height * 0.5); + ? (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 @@ -626,7 +665,7 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, return (
- {orientation === 'vertical' + {effectiveOrientation === 'vertical' ? : } @@ -635,7 +674,7 @@ export const ChargeVizPanel: React.FC = ({ data, width, height, options, {(showAnyStats || hasExtras) && !isTiny && (
{/* Load gauge card */} - {hasExtras && !isCompact && ( + {hasLoadExtra && !isCompact && (
= ({ data, width, height, options, )} {/* Compact load fallback when gauge doesn't fit */} - {hasExtras && isCompact && ( + {hasLoadExtra && isCompact && (
= ({ data, width, height, options,
)} + {/* Time remaining */} + {hasTimeLeftExtra && ( +
+ ⏱ +
+
+ Time remaining +
+ + {formatMinutes(timeLeftValue!)} + +
+
+ )} + {/* Status badge */} {showStatus && !isCompact && (
diff --git a/src/img/logo.svg b/src/img/logo.svg index 3d284de..edd0395 100644 --- a/src/img/logo.svg +++ b/src/img/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index 35c9ad5..2dfd88b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -5,15 +5,12 @@ import { ChargeVizOptions } from './types'; export const plugin = new PanelPlugin(ChargeVizPanel).setPanelOptions( (builder) => { // -- Field Mapping category (top) -- - builder.addFieldNamePicker({ + builder.addTextInput({ path: 'chargeField', - name: 'Charge field', + name: 'Charge field name', + defaultValue: '', 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', - }, + description: 'Name of the numeric field for battery charge (0–100%). Leave blank to use the first number field.', }); builder.addBooleanSwitch({ @@ -24,34 +21,46 @@ export const plugin = new PanelPlugin(ChargeVizPanel).setPanel description: 'Show a second metric representing current load / power draw.', }); - builder.addFieldNamePicker({ + builder.addTextInput({ path: 'loadField', - name: 'Load field', + name: 'Load field name', + defaultValue: '', category: ['Field Mapping'], - description: 'Numeric field for load percentage (0–100%).', - settings: { - filter: (f) => f.type === 'number', - noFieldsMessage: 'No numeric fields found', - }, + description: 'Name of the numeric field for load percentage (0–100%).', showIf: (opts) => opts.enableLoad === true, }); + builder.addBooleanSwitch({ + path: 'enableTimeLeft', + name: 'Enable time remaining', + defaultValue: false, + category: ['Field Mapping'], + description: 'Show estimated time remaining from a data field (value in minutes).', + }); + + builder.addTextInput({ + path: 'timeLeftField', + name: 'Time remaining field name', + defaultValue: '', + category: ['Field Mapping'], + description: 'Name of the numeric field for estimated time remaining (in minutes).', + showIf: (opts) => opts.enableTimeLeft === 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.', + description: 'Use a data field to determine charging / discharging state instead of inferring from the trend.', }); - builder.addFieldNamePicker({ + builder.addTextInput({ path: 'stateField', - name: 'State field', + name: 'State field name', + defaultValue: '', category: ['Field Mapping'], - description: 'String or numeric field whose value indicates the battery state.', - settings: { - noFieldsMessage: 'No fields found', - }, + description: 'Name of the field whose value indicates battery state.', showIf: (opts) => opts.enableStateField === true, }); @@ -137,7 +146,7 @@ export const plugin = new PanelPlugin(ChargeVizPanel).setPanel { value: 'horizontal', label: 'Horizontal' }, ], }, - description: 'Battery orientation. Horizontal works well in wide, short panels.', + description: 'Preferred orientation. Auto-reverts to vertical if the panel is too narrow for horizontal.', }); builder.addBooleanSwitch({ diff --git a/src/plugin.json b/src/plugin.json index 51bde9d..c557d7d 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", "type": "panel", - "name": "Battery Panel", + "name": "UPS Visualisation", "id": "andydixon-chargevizbattery-panel", "info": { "keywords": ["panel"], diff --git a/src/types.ts b/src/types.ts index 2bf08b5..6dc6b89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ export interface ChargeVizOptions { chargeField: string; enableLoad: boolean; loadField: string; + enableTimeLeft: boolean; + timeLeftField: string; enableStateField: boolean; stateField: string; stateChargingValue: string;