diff --git a/src/formatter.ts b/src/formatter.ts index d21b51d..fd68c5d 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -14,7 +14,7 @@ type FormattedData = { [index: number]: DataPoint }; * Format and clean up the panel data. * All series are expected to be time * series and have unique field names. - * @param data The data object generated by the panel. + * @param series The data object generated by the panel. * @returns A formatted object of panel data. * `{ timestamp : { field : value, ... }, ... }` */ diff --git a/src/module.ts b/src/module.ts index f363e9a..38f97d7 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,5 +1,5 @@ import { PanelPlugin, SelectableValue } from '@grafana/data'; -import { PsyOptions } from './types'; +import { DataSeries, PsyOptions } from './types'; import { PsyPanel } from './panel'; import { Psychart } from 'psychart'; import { icons } from 'icons'; @@ -116,169 +116,179 @@ export const plugin = new PanelPlugin(PsyPanel).setPanelOptions((bui delete context.options.series[+key]; } } - // Generate controls for data series - for (let i = 0; i < context.options!.count; i++) { - const subcategory: string = 'Series ' + JMath.itoa(i); - context.options.series[i] = cleanDataOptions(context.options.series[i] || {}); - builder - .addTextInput({ - path: 'series.' + i + '.legend', - name: 'Legend', - description: 'Add a label to this data series.', - defaultValue: undefined, - category: [subcategory], - settings: { - maxLength: 50, - placeholder: subcategory, + builder + .addNestedOptions({ + path: 'series', + category: ['Data options'], + build(subbuilder, subcontext) { + subcontext.options = subcontext.options || {}; + // Generate controls for data series + for (let i = 0; i < context.options!.count; i++) { + // Force clean data options + subcontext.options[i] = cleanDataOptions(subcontext.options[i] || {}); + // Use legend as subcategory or default string if none exists + const subcategory: string = subcontext.options[i].legend || 'Series ' + JMath.itoa(i); + subbuilder + .addTextInput({ + path: i + '.legend', + name: 'Legend', + description: 'Add a label to this data series.', + defaultValue: undefined, + category: [subcategory], + settings: { + maxLength: 50, + placeholder: subcategory, + } + }) + .addSelect({ + path: i + '.measurements', + name: 'Measurements', + description: 'Select which series are being measured.', + defaultValue: subcontext.options![i].measurements, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: false, + options: [ + { + value: 'dbwb', + label: 'Dry Bulb & Wet Bulb', + }, + { + value: 'dbdp', + label: 'Dry Bulb & Dew Point', + }, + { + value: 'dbrh', + label: 'Dry Bulb & Rel. Humidity', + }, + ], + }, + showIf: (x) => !!(x[i].legend), + }) + .addSelect({ + path: i + '.dryBulb', + name: 'Dry Bulb Series', + description: 'Select a series that measures the dry bulb temperature.', + defaultValue: subcontext.options[i].dryBulb, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: true, + options: fieldOptions, + }, + showIf: (x) => !!(x[i].legend), + }) + .addSelect({ + path: i + '.wetBulb', + name: 'Wet Bulb Series', + description: 'Select a series that measures the wet bulb temperature.', + defaultValue: subcontext.options[i].wetBulb, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: true, + options: fieldOptions, + }, + showIf: (x) => !!(x[i].legend && x[i].measurements === 'dbwb'), + }) + .addSelect({ + path: i + '.dewPoint', + name: 'Dew Point Series', + description: 'Select a series that measures the dew point temperature.', + defaultValue: subcontext.options[i].dewPoint, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: true, + options: fieldOptions, + }, + showIf: (x) => !!(x[i].legend && x[i].measurements === 'dbdp'), + }) + .addSelect({ + path: i + '.relHum', + name: 'Relative Humidity Series', + description: 'Select a series that measures the relative humidity.', + defaultValue: subcontext.options[i].relHum, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: true, + options: fieldOptions, + }, + showIf: (x) => !!(x[i].legend && x[i].measurements === 'dbrh'), + }) + .addRadio({ + path: i + '.relHumType', + name: 'Relative Humidity Type', + description: 'Choose how relative humidity is actively being measured.', + category: [subcategory], + defaultValue: subcontext.options[i].relHumType, + settings: { + allowCustomValue: false, + isClearable: false, + options: [ + { + value: 'percent', + label: '100%', + }, + { + value: 'float', + label: '0.0-1.0', + }, + ], + }, + showIf: (x) => !!(x[i].legend && x[i].measurements === 'dbrh'), + }) + .addSliderInput({ + path: i + '.pointRadius', + name: 'Point Size', + description: 'Enter the point radius, in pixels.', + defaultValue: subcontext.options[i].pointRadius, + category: [subcategory], + settings: { + min: 1, + max: 10, + step: 1, + }, + showIf: (x) => !!(x[i].legend), + }) + .addBooleanSwitch({ + path: i + '.line', + name: 'Show Line', + description: 'Connect data points with a line?', + defaultValue: subcontext.options[i].line, + category: [subcategory], + showIf: (x) => !!(x[i].legend), + }) + .addSelect({ + path: i + '.gradient', + name: 'Gradient', + description: 'The series color gradient.', + defaultValue: subcontext.options[i].gradient, + category: [subcategory], + settings: { + allowCustomValue: false, + isClearable: false, + options: Psychart.getGradientNames().map(name => { + return { + value: name, + label: name, + imgUrl: icons[name], + }; + }), + }, + showIf: (x) => !!(x[i].legend), + }) + .addBooleanSwitch({ + path: i + '.advanced', + name: 'Show Advanced State Variables', + description: 'Additionally show humidity ratio, vapor pressure, enthalpy, and specific volume on hover.', + defaultValue: subcontext.options[i].advanced, + category: [subcategory], + showIf: (x) => !!(x[i].legend), + }); } - }) - .addSelect({ - path: 'series.' + i + '.measurements', - name: 'Measurements', - description: 'Select which series are being measured.', - defaultValue: context.options.series[i].measurements, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: false, - options: [ - { - value: 'dbwb', - label: 'Dry Bulb & Wet Bulb', - }, - { - value: 'dbdp', - label: 'Dry Bulb & Dew Point', - }, - { - value: 'dbrh', - label: 'Dry Bulb & Rel. Humidity', - }, - ], - }, - showIf: (x) => !!(x.series?.[i]?.legend), - }) - .addSelect({ - path: 'series.' + i + '.dryBulb', - name: 'Dry Bulb Series', - description: 'Select a series that measures the dry bulb temperature.', - defaultValue: context.options.series[i].dryBulb, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: true, - options: fieldOptions, - }, - showIf: (x) => !!(x.series?.[i]?.legend), - }) - .addSelect({ - path: 'series.' + i + '.wetBulb', - name: 'Wet Bulb Series', - description: 'Select a series that measures the wet bulb temperature.', - defaultValue: context.options.series[i].wetBulb, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: true, - options: fieldOptions, - }, - showIf: (x) => !!(x.series?.[i]?.legend && x.series?.[i]?.measurements === 'dbwb'), - }) - .addSelect({ - path: 'series.' + i + '.dewPoint', - name: 'Dew Point Series', - description: 'Select a series that measures the dew point temperature.', - defaultValue: context.options.series[i].dewPoint, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: true, - options: fieldOptions, - }, - showIf: (x) => !!(x.series?.[i]?.legend && x.series?.[i]?.measurements === 'dbdp'), - }) - .addSelect({ - path: 'series.' + i + '.relHum', - name: 'Relative Humidity Series', - description: 'Select a series that measures the relative humidity.', - defaultValue: context.options.series[i].relHum, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: true, - options: fieldOptions, - }, - showIf: (x) => !!(x.series?.[i]?.legend && x.series?.[i]?.measurements === 'dbrh'), - }) - .addRadio({ - path: 'series.' + i + '.relHumType', - name: 'Relative Humidity Type', - description: 'Choose how relative humidity is actively being measured.', - category: [subcategory], - defaultValue: context.options.series[i].relHumType, - settings: { - allowCustomValue: false, - isClearable: false, - options: [ - { - value: 'percent', - label: '100%', - }, - { - value: 'float', - label: '0.0-1.0', - }, - ], - }, - showIf: (x) => !!(x.series?.[i]?.legend && x.series?.[i]?.measurements === 'dbrh'), - }) - .addSliderInput({ - path: 'series.' + i + '.pointRadius', - name: 'Point Size', - description: 'Enter the point radius, in pixels.', - defaultValue: context.options.series[i].pointRadius, - category: [subcategory], - settings: { - min: 1, - max: 10, - step: 1, - }, - showIf: (x) => !!(x.series?.[i]?.legend), - }) - .addBooleanSwitch({ - path: 'series.' + i + '.line', - name: 'Show Line', - description: 'Connect data points with a line?', - defaultValue: context.options.series[i].line, - category: [subcategory], - showIf: (x) => !!(x.series?.[i]?.legend), - }) - .addSelect({ - path: 'series.' + i + '.gradient', - name: 'Gradient', - description: 'The series color gradient.', - defaultValue: context.options.series[i].gradient, - category: [subcategory], - settings: { - allowCustomValue: false, - isClearable: false, - options: Psychart.getGradientNames().map(name => { - return { - value: name, - label: name, - imgUrl: icons[name], - }; - }), - }, - showIf: (x) => !!(x.series?.[i]?.legend), - }) - .addBooleanSwitch({ - path: 'series.' + i + '.advanced', - name: 'Show Advanced State Variables', - description: 'Additionally show humidity ratio, vapor pressure, enthalpy, and specific volume on hover.', - defaultValue: context.options.series[i].advanced, - category: [subcategory], - showIf: (x) => !!(x.series?.[i]?.legend), - }); - } + }, + }); }); diff --git a/src/panel.tsx b/src/panel.tsx index c473264..fee7e0c 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -3,7 +3,7 @@ import { PanelProps } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; import { Layout, PsyOptions } from './types'; import { Container } from './container'; -import { format } from './formatter'; +import { format, getFieldList } from './formatter'; import { Psychart } from './psychart'; export const PsyPanel: React.FC> = ({ options, data, width, height }) => { @@ -13,30 +13,31 @@ export const PsyPanel: React.FC> = ({ options, data, widt style = Psychart.getDefaultStyleOptions(isDarkTheme), psychart = new Psychart(layout, options, style), formatted = format(data.series), + fieldList = getFieldList(formatted), startTime = data.timeRange.from.unix() * 1e3, endTime = data.timeRange.to.unix() * 1e3; for (let t in formatted) { - for (let f = 0; f < options.count; f++) { + for (let f in options.series || {}) { // Skip unset series - if (!options.series?.[f] || !options.series?.[f].legend) { + if (!options.series[f].legend) { continue; } switch (options.series[f].measurements) { case ('dbwb'): { - if (typeof options.series[f].dryBulb === 'string' && typeof options.series[f].wetBulb === 'string') { - psychart.plot({ db: formatted[t][options.series[f].dryBulb], wb: formatted[t][options.series[f].wetBulb] }, options.series[f], +t, startTime, endTime); + if (fieldList.includes(options.series[f].dryBulb) && fieldList.includes(options.series[f].wetBulb)) { + psychart.plot({ db: formatted[t][options.series[f].dryBulb], wb: formatted[t][options.series[f].wetBulb] }, +f, +t, startTime, endTime); } break; } case ('dbrh'): { - if (typeof options.series[f].dryBulb === 'string' && typeof options.series[f].relHum === 'string') { - psychart.plot({ db: formatted[t][options.series[f].dryBulb], rh: formatted[t][options.series[f].relHum] }, options.series[f], +t, startTime, endTime); + if (fieldList.includes(options.series[f].dryBulb) && fieldList.includes(options.series[f].relHum)) { + psychart.plot({ db: formatted[t][options.series[f].dryBulb], rh: formatted[t][options.series[f].relHum] }, +f, +t, startTime, endTime); } break; } case ('dbdp'): { - if (typeof options.series[f].dryBulb === 'string' && typeof options.series[f].dewPoint === 'string') { - psychart.plot({ db: formatted[t][options.series[f].dryBulb], dp: formatted[t][options.series[f].dewPoint] }, options.series[f], +t, startTime, endTime); + if (fieldList.includes(options.series[f].dryBulb) && fieldList.includes(options.series[f].dewPoint)) { + psychart.plot({ db: formatted[t][options.series[f].dryBulb], dp: formatted[t][options.series[f].dewPoint] }, +f, +t, startTime, endTime); } break; } diff --git a/src/psychart.ts b/src/psychart.ts index 3cf6135..3515107 100644 --- a/src/psychart.ts +++ b/src/psychart.ts @@ -196,7 +196,7 @@ export class Psychart { /** * The last states plotted on Psychart for each series. */ - private lastState: { [index: string]: PsyState } = {}; + private lastState: { [index: number]: PsyState } = {}; /** * The timestamp of which Psychart was initialized. For plotting, this represents the origin. */ @@ -505,7 +505,9 @@ export class Psychart { /** * Plot one psychrometric state onto the psychrometric chart. */ - plot(state: Datum, options: DataOptions, time: number = Date.now(), startTime: number = this.startTime, endTime: number = this.endTime): void { + plot(state: Datum, id = 0, time: number = Date.now(), startTime: number = this.startTime, endTime: number = this.endTime): void { + // Grab the corresponding data options + const options: DataOptions = this.config.series[id]; // Check for invalid timestamps. if (!Number.isFinite(time)) { throw new Error('Data timestamp is invalid for series ' + options.legend + '.'); @@ -524,10 +526,10 @@ export class Psychart { const normalized = JMath.normalize(time, startTime, endTime), color = Color.gradient(normalized, Psychart.gradients[options.gradient as GradientName] ?? Psychart.gradients.Viridis); // Determine whether to connect the states with a line - if (!!this.lastState[options.legend]) { - this.g.trends.appendChild(this.createLine([this.lastState[options.legend], currentState], color, +options.line)); + if (!!this.lastState[id]) { + this.g.trends.appendChild(this.createLine([this.lastState[id], currentState], color, +options.line)); } - this.lastState[options.legend] = currentState; + this.lastState[id] = currentState; // Define a 0-length path element and assign its attributes. const point = document.createElementNS(NS, 'path'); point.setAttribute('fill', 'none'); diff --git a/src/types.ts b/src/types.ts index cffc582..bef4681 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import { Color } from './color'; export type RegionName = 'Summer (sitting)' | 'Summer (walking)' | 'Summer (light work)' | 'Winter (sitting)' | 'Winter (walking)' | 'Winter (light work)' | 'Data Center A4' | 'Data Center A3' | 'Data Center A2' | 'Data Center A1' | 'Data Center Recommended (low pollutants)' | 'Data Center Recommended (high pollutants)'; export type GradientName = 'Viridis' | 'Inferno' | 'Magma' | 'Plasma' | 'Blue'; +export type DataSeries = { [index: number]: DataOptions }; export interface Point { /** @@ -118,7 +119,7 @@ export interface PsyOptions { /** * The data series information. */ - series: { [index: number]: DataOptions }; + series: DataSeries; } export interface DataOptions {