From 461bdeced08b35819e9c085068261de9a1261377 Mon Sep 17 00:00:00 2001 From: prestoncraw Date: Mon, 3 Aug 2026 11:05:12 -0400 Subject: [PATCH 1/5] Initial implementation of statistics widget --- .../NavBar/TrendDataNavbarButtons.tsx | 32 ++++ .../TSX/Components/TrendData/TrendData.tsx | 2 +- .../TrendData/TrendPlot/Histogram.tsx | 2 +- .../TrendPlot/Statistics/Statistics.tsx | 181 ++++++++++++++++++ .../TrendPlot/Statistics/StatisticsData.ts | 135 +++++++++++++ .../TrendPlot/TrendWidgetRegistry.ts | 4 + .../{TrendPlot.tsx => TrendWidgetWrapper.tsx} | 24 ++- SEBrowser/Scripts/TSX/global.d.ts | 2 +- 8 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx create mode 100644 SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts rename SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/{TrendPlot.tsx => TrendWidgetWrapper.tsx} (98%) diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx index 6b36c973..01b786f9 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx @@ -216,6 +216,17 @@ const TrendDataNavbarButtons = (props: IProps) => { PlotFilter: props.LinePlot }]); } + }, { + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => { + if (props.SelectedSet.size === 0) return; + const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); + props.AddNewCharts([{ + TimeFilter: props.TimeFilter, Type: 'Statistics', Channels: selectedChannels, ID: CreateGuid(), + PlotFilter: props.LinePlot + }]); + } }]} Size='sm' /> @@ -223,6 +234,7 @@ const TrendDataNavbarButtons = (props: IProps) => {

Add All Selected Channels to Single Line Plot

Add All Selected Channels to Single Histogram (Dropdown)

+

Add All Selected Channels to Single Statistics Table (Dropdown)

{props.SelectedSet.size === 0 ?

{'Requires a Selected Channel'}

: null}
{ PlotFilter: props.LinePlot }))); } + }, { + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => { + if (props.SelectedSet.size === 0) return; + const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); + const meterPlotChannels: TrendSearch.ITrendChannel[][] = []; + selectedChannels.forEach(channel => { + const listIndex = meterPlotChannels.findIndex(channelList => channelList[0].MeterKey === channel.MeterKey); + if (listIndex > -1) + meterPlotChannels[listIndex].push(channel); + else + meterPlotChannels.push([channel]); + }); + props.AddNewCharts(meterPlotChannels.map(channelList => ({ + TimeFilter: props.TimeFilter, Type: 'Statistics', Channels: channelList, ID: CreateGuid(), + PlotFilter: props.LinePlot + }))); + } }]} Size='sm' /> @@ -277,6 +308,7 @@ const TrendDataNavbarButtons = (props: IProps) => {

Add Selected Channels to Line Plots Separated by Meter

Add Selected Channels to Histograms Separated by Meter (Dropdown)

+

Add Selected Channels to Statistics Tables Separated by Meter (Dropdown)

{props.SelectedSet.size === 0 ?

Requires a Selected Channel

: null}
+ ); + })} +
+ +
+ + Data={sortedRows} + SortKey={sortKey} + Ascending={ascending} + OnSort={({ colField }) => { + if (colField == null || colField === 'Key') return; + if (colField === sortKey) + setAscending(current => !current); + else { + setSortKey(colField); + setAscending(true); + } + }} + KeySelector={row => row.Key} + TableClass="table table-hover" + TableStyle={{ flex: 'none', height: 'auto', width: tableMinimumWidth, overflow: 'visible' }} + TheadStyle={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'Canvas' }} + TbodyStyle={{ flex: 'none', overflow: 'visible' }} + > + + Key="Statistic" + Field="Statistic" + > + Statistic + + {numericColumns.map(column => + + key={column.Field} + Key={column.Field} + Field={column.Field} + Content={({ item }) => formatValue(item[column.Field])} + > + {column.Label} + + )} + +
+ Some selected Statistics have no finite data for the selected Time Window. + + ); +}); + +const isChannelID = (channelID?: number): channelID is number => channelID != null && Number.isFinite(channelID); + +const formatValue = (value: number | null): string => value == null ? '—' : value.toLocaleString(undefined, { maximumFractionDigits: 3 }); + +const sortRows = (rows: IStatisticsRow[], sortKey: StatisticsSortKey, ascending: boolean): IStatisticsRow[] => + orderBy(rows, [row => row[sortKey] == null, sortKey], ['asc', ascending ? 'asc' : 'desc']); + +export { Statistics }; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts new file mode 100644 index 00000000..30b16ef5 --- /dev/null +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts @@ -0,0 +1,135 @@ +//****************************************************************************************************** +// StatisticsData.ts - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, software distributed under the License is distributed on an "AS-IS" +// BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the License +// for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 07/31/26 - Preston Crawford +// Generated original version of source code. +// +//****************************************************************************************************** +import type { IMultiCheckboxOption, TrendSearch } from '../../../../global'; + +export const statisticSeriesTypes = ['Minimum', 'Average', 'Maximum'] as const; + +export interface IStatisticsRow { + Key: string, + Statistic: string, + Min: number | null, + CP005: number | null, + CP01: number | null, + CP05: number | null, + CP25: number | null, + Avg: number | null, + CP50: number | null, + CP75: number | null, + CP95: number | null, + CP99: number | null, + CP995: number | null, + Max: number | null, + Count: number, + StdDev: number | null +} + +type StatisticsValues = Omit; + +/** Calculates summary statistics from finite values only. */ +export const calculateStatistics = (values: number[]): StatisticsValues => { + const sortedValues = values.filter(Number.isFinite).sort((left, right) => left - right); + const count = sortedValues.length; + if (count === 0) + return emptyStatistics; + + const average = sortedValues.reduce((sum, value) => sum + value, 0) / count; + const standardDeviation = count === 1 ? null : Math.sqrt( + sortedValues.reduce((sum, value) => sum + Math.pow(value - average, 2), 0) / (count - 1) + ); + + return { + Min: sortedValues[0], + CP005: percentile(sortedValues, 0.005), + CP01: percentile(sortedValues, 0.01), + CP05: percentile(sortedValues, 0.05), + CP25: percentile(sortedValues, 0.25), + Avg: average, + CP50: percentile(sortedValues, 0.5), + CP75: percentile(sortedValues, 0.75), + CP95: percentile(sortedValues, 0.95), + CP99: percentile(sortedValues, 0.99), + CP995: percentile(sortedValues, 0.995), + Max: sortedValues[count - 1], + Count: count, + StdDev: standardDeviation + }; +}; + +/** Builds one isolated row for every selected channel and minimum/average/maximum series. */ +export const buildStatisticsRows = (points?: TrendSearch.IPQData[] | null, channelInfo?: TrendSearch.ISeriesSettings[] | null, + plotFilter?: IMultiCheckboxOption[] | null): IStatisticsRow[] => { + const pointsByTag = new Map(); + (points ?? []).forEach(point => { + if (typeof point?.Tag !== 'string') return; + const tag = point.Tag.toLowerCase(); + const tagPoints = pointsByTag.get(tag); + if (tagPoints == null) + pointsByTag.set(tag, [point]); + else + tagPoints.push(point); + }); + + const selectedSeries = statisticSeriesTypes.filter(type => plotFilter?.find(option => option?.Value === type)?.Selected ?? true); + return (channelInfo ?? []).flatMap(channel => { + if (channel?.Channel == null) return []; + const tag = getChannelTag(channel.Channel.ChannelID); + const channelPoints = tag == null ? [] : pointsByTag.get(tag) ?? []; + const settings = channel.Settings as TrendSearch.ILineSeriesSettings | null | undefined; + return selectedSeries.map(type => ({ + Key: `${channel.Channel.ID}_${type}`, + Statistic: settings?.[type]?.Label ?? type, + ...calculateStatistics(channelPoints.map(point => point[type])) + })); + }); +}; + +/** Returns the lower-case, eight-character HIDS tag for a channel ID. */ +export const getChannelTag = (channelID?: number): string | null => { + if (channelID == null || !Number.isFinite(channelID)) return null; + return channelID.toString(16).padStart(8, '0').toLowerCase(); +}; + +const percentile = (sortedValues: number[], probability: number): number => { + const index = (sortedValues.length - 1) * probability; + const lowerIndex = Math.floor(index); + const upperIndex = Math.ceil(index); + if (lowerIndex === upperIndex) return sortedValues[lowerIndex]; + return sortedValues[lowerIndex] + (sortedValues[upperIndex] - sortedValues[lowerIndex]) * (index - lowerIndex); +}; + +const emptyStatistics: StatisticsValues = { + Min: null, + CP005: null, + CP01: null, + CP05: null, + CP25: null, + Avg: null, + CP50: null, + CP75: null, + CP95: null, + CP99: null, + CP995: null, + Max: null, + Count: 0, + StdDev: null +}; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts index 9f5b1999..7dfa0eec 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts @@ -25,6 +25,7 @@ import { IMultiCheckboxOption, SEBrowser, TrendSearch } from '../../../global'; import { CyclicHistogram } from './CyclicHistogram'; import { Histogram } from './Histogram'; import { LineGraph } from './LineGraph'; +import { Statistics } from './Statistics/Statistics'; import { PlotSettingsTab } from '../Settings/OverlayTabs/PlotSettingsTab'; import type { IPlotSettingsProps } from '../Settings/OverlayTabs/PlotSettingsTab'; import { MarkerTab } from '../Settings/OverlayTabs/MarkerTab'; @@ -111,5 +112,8 @@ export const TrendWidgetRegistry: Record { const getDefaultValue: (channel: TrendSearch.ITrendChannel, passChannelToColor: boolean) => TrendSearch.ISeriesSettings = (channel, passChannelToColor) => { switch (props.Plot.Type) { case 'Line': - case 'Histogram': default: { + case 'Histogram': + case 'Statistics': default: { const color: TrendSearch.IColor = getColor(props.LineDefaults.Colors.Default, passChannelToColor ? channel : undefined); const settings: TrendSearch.ISeriesSettings = { Channel: channel, @@ -262,7 +263,7 @@ const TrendPlot = (props: IContainerProps) => { } } }; - const seriesToConfigure = props.Plot.Type === 'Histogram' ? + const seriesToConfigure = props.Plot.Type === 'Histogram' || props.Plot.Type === 'Statistics' ? (['Minimum', 'Average', 'Maximum'] as const).map(type => channel.Series?.find(series => series.TypeName === type) ?? { ID: 0, ChannelID: channel.ChannelID, TypeName: type, TypeDescription: type }) : (channel.Series ?? []); @@ -298,7 +299,7 @@ const TrendPlot = (props: IContainerProps) => { }; let passChannel = false; - if (plotAllSeriesSettings == null && (props.Plot.Type === "Line" || props.Plot.Type === "Histogram")) { + if (plotAllSeriesSettings == null && (props.Plot.Type === "Line" || props.Plot.Type === "Histogram" || props.Plot.Type === "Statistics")) { passChannel = true; colorIndex.current = { ind: -1, assetMap: new Map() }; if (props.LineDefaults.Colors.Default.ApplyType === "Asset") buildAssetDictionary(props.Plot.Channels); @@ -308,10 +309,11 @@ const TrendPlot = (props: IContainerProps) => { props.Plot.Channels.map(channel => { const oldSettings = plotAllSeriesSettings?.find(oldSetting => oldSetting.Channel.ID === channel.ID) if (oldSettings === undefined) return getDefaultValue(channel, passChannel); - if (props.Plot.Type === 'Line' || props.Plot.Type === 'Histogram') { + if (props.Plot.Type === 'Line' || props.Plot.Type === 'Histogram' || props.Plot.Type === 'Statistics') { const lineSettings = oldSettings.Settings as TrendSearch.ILineSeriesSettings; Object.keys(lineSettings).forEach(key => { - const series = channel.Series.find(series => series.TypeName === key); + const series = channel.Series?.find(series => series.TypeName === key) ?? + (props.Plot.Type === 'Statistics' ? { ID: 0, ChannelID: channel.ChannelID, TypeName: key, TypeDescription: key } : undefined); const label = constructLabel(channel, series); lineSettings[key].Label = label; if (props.Plot.Type === 'Histogram') { @@ -498,9 +500,11 @@ const TrendPlot = (props: IContainerProps) => { ); + }} + > + {props.Plot.Type === 'Statistics' ? : TrashCan} + + ); const overlayButton = widgetDefinition.Settings == null ? null : ( ); const createCustomButton = React.useCallback((cSymbol: string, cSelect: customSelects, cCursor: string, cHighlight: 'none' | 'horizontal' | 'vertical') => - - - Hides Navbar - - - -

Settings for All Current and/or Future Plots

-
- - -

Drag-and-Drop Reorder Plots

- {props.PlotIds.length === 0 ? -

{'Requires an Active Plot'}

- : null} -
- - -

Select All Channels in Table

- {(props.TrendChannels.length === 0) ?

{'Table has no Channels to Select'}

: null} -
- + + Hides Navbar + + + +

Settings for All Current and/or Future Plots

+
+ + +

Drag-and-Drop Reorder Plots

+ {props.PlotIds.length === 0 ? +

{'Requires an Active Plot'}

+ : null} +
+ + +

Select All Channels in Table

+ {(props.TrendChannels.length === 0) ?

{'Table has no Channels to Select'}

: null} +
+ - -

Save All Plots to PDF

- {props.PlotIds.length === 0 ?

{'Requires an Active Plot'}

: null} -
+ }); + Promise.all(handles).then(() => { + const pdf = new jspdf("l", "mm", "a4"); + const pdfPageHeight = pdf.internal.pageSize.getHeight(); + const pdfPageWidth = pdf.internal.pageSize.getWidth(); + let widthLeft = pdfPageWidth; + let heightLeft = pdfPageHeight; + let biggestRowHeight = 0; + allImgData.forEach((imgData, ind) => { + const plot = props.PlotIds[ind]; + const imgWidth = pdfPageWidth * plot.Width / 100; + const imgProps = pdf.getImageProperties(imgData); + const imgHeight = imgProps.height * imgWidth / imgProps.width; + if (widthLeft - imgWidth < 0) { + widthLeft = pdfPageWidth; + heightLeft -= biggestRowHeight; + biggestRowHeight = 0; + if (heightLeft - imgHeight < 0) { + pdf.addPage(); + heightLeft = pdfPageHeight; + } + } + const currentHeight = pdfPageHeight - heightLeft; + const currentWidth = pdfPageWidth - widthLeft; + pdf.addImage(imgData, "PNG", currentWidth, currentHeight, imgWidth, imgHeight); + widthLeft -= imgWidth; + biggestRowHeight = Math.max(imgHeight, biggestRowHeight); + window.URL.revokeObjectURL(imgData); + }); + pdf.save('AllTrendPlots.pdf'); + }); + } + }} + data-tooltip='Capture' onMouseEnter={() => setHover('Capture')} onMouseLeave={() => setHover('None')}> + + + +

Save All Plots to PDF

+ {props.PlotIds.length === 0 ?

{'Requires an Active Plot'}

: null} +
- - -

Remove All Plots

- {props.PlotIds.length === 0 ?

{'Requires an Active Plot'}

: null} -
-
setHover('Single-Plot-Group')} onMouseLeave={() => setHover('None')}> - } - ContainerStyle={{ width: '100%' }} - Disabled={props.SelectedSet.size === 0} - Callback={() => { - if (props.SelectedSet.size === 0) return; - const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - props.AddNewCharts([{ - TimeFilter: props.TimeFilter, Type: 'Line', Channels: selectedChannels, ID: CreateGuid(), - PlotFilter: props.LinePlot - }]); - }} - Options={[{ - Label: , - Disabled: props.SelectedSet.size === 0, - Callback: () => { - if (props.SelectedSet.size === 0) return; - const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - props.AddNewCharts([{ - TimeFilter: props.TimeFilter, Type: 'Histogram', Channels: selectedChannels, ID: CreateGuid(), - PlotFilter: props.LinePlot - }]); - } - }, { - Label: , - Disabled: props.SelectedSet.size === 0, - Callback: () => { - if (props.SelectedSet.size === 0) return; - const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - props.AddNewCharts([{ - TimeFilter: props.TimeFilter, Type: 'Statistics', Channels: selectedChannels, ID: CreateGuid(), - PlotFilter: props.LinePlot - }]); - } - }]} - Size='sm' - /> -
- -

Add All Selected Channels to Single Line Plot

-

Add All Selected Channels to Single Histogram (Dropdown)

-

Add All Selected Channels to Single Statistics Table (Dropdown)

- {props.SelectedSet.size === 0 ?

{'Requires a Selected Channel'}

: null} -
-
setHover('Meter-Plot-Group')} onMouseLeave={() => setHover('None')}> - } - ContainerStyle={{ width: '100%' }} - Disabled={props.SelectedSet.size === 0} - Callback={() => { - if (props.SelectedSet.size === 0) return; - const selectedChannels: TrendSearch.ITrendChannel[] = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - const meterPlotChannels: TrendSearch.ITrendChannel[][] = []; - selectedChannels.forEach(channel => { - const listIndex = meterPlotChannels.findIndex(channelList => channelList[0].MeterKey === channel.MeterKey); - if (listIndex > -1) - meterPlotChannels[listIndex].push(channel); - else - meterPlotChannels.push([channel]); - }); - props.AddNewCharts( - meterPlotChannels.map(channelList => { - return ({ - TimeFilter: props.TimeFilter, Type: 'Line', Channels: channelList, ID: CreateGuid(), - PlotFilter: props.LinePlot - }); - }) - ); - }} - Options={[{ - Label: , - Disabled: props.SelectedSet.size === 0, - Callback: () => { - if (props.SelectedSet.size === 0) return; - const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - const meterPlotChannels: TrendSearch.ITrendChannel[][] = []; - selectedChannels.forEach(channel => { - const listIndex = meterPlotChannels.findIndex(channelList => channelList[0].MeterKey === channel.MeterKey); - if (listIndex > -1) - meterPlotChannels[listIndex].push(channel); - else - meterPlotChannels.push([channel]); - }); - props.AddNewCharts(meterPlotChannels.map(channelList => ({ - TimeFilter: props.TimeFilter, Type: 'Histogram', Channels: channelList, ID: CreateGuid(), - PlotFilter: props.LinePlot - }))); - } - }, { - Label: , - Disabled: props.SelectedSet.size === 0, - Callback: () => { - if (props.SelectedSet.size === 0) return; - const selectedChannels = props.TrendChannels.filter(chan => props.SelectedSet.has(chan.ID)); - const meterPlotChannels: TrendSearch.ITrendChannel[][] = []; - selectedChannels.forEach(channel => { - const listIndex = meterPlotChannels.findIndex(channelList => channelList[0].MeterKey === channel.MeterKey); - if (listIndex > -1) - meterPlotChannels[listIndex].push(channel); - else - meterPlotChannels.push([channel]); - }); - props.AddNewCharts(meterPlotChannels.map(channelList => ({ - TimeFilter: props.TimeFilter, Type: 'Statistics', Channels: channelList, ID: CreateGuid(), - PlotFilter: props.LinePlot - }))); - } - }]} - Size='sm' - /> -
- -

Add Selected Channels to Line Plots Separated by Meter

-

Add Selected Channels to Histograms Separated by Meter (Dropdown)

-

Add Selected Channels to Statistics Tables Separated by Meter (Dropdown)

- {props.SelectedSet.size === 0 ?

Requires a Selected Channel

: null} -
- - -

Add Selected Channels to New Plots Separated by Channel Group

- {props.SelectedSet.size === 0 ?

Requires a Selected Channel

: null} -
- - -

Add Selected Channel to New Cyclic Histogram Plot

- {props.SelectedSet.size !== 1 ?

Requires a Single Channel Selection

: null} -
+ + +

Remove All Plots

+ {props.PlotIds.length === 0 ?

{'Requires an Active Plot'}

: null} +
+
setHover('Single-Plot-Group')} onMouseLeave={() => setHover('None')}> + } + ContainerStyle={{ width: '100%' }} + Disabled={props.SelectedSet.size === 0} + Callback={() => addSelectedPlots('Line')} + Options={[{ + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Histogram') + }, { + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Statistics') + }]} + Size='sm' + /> +
+ +

Add All Selected Channels to Single Line Plot

+

Add All Selected Channels to Single Histogram (Dropdown)

+

Add All Selected Channels to Single Statistics Table (Dropdown)

+ {props.SelectedSet.size === 0 ?

{'Requires a Selected Channel'}

: null} +
+
setHover('Meter-Plot-Group')} onMouseLeave={() => setHover('None')}> + } + ContainerStyle={{ width: '100%' }} + Disabled={props.SelectedSet.size === 0} + Callback={() => addSelectedPlots('Line', channel => channel.MeterKey)} + Options={[{ + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Histogram', channel => channel.MeterKey) + }, { + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Statistics', channel => channel.MeterKey) + }]} + Size='sm' + /> +
+ +

Add Selected Channels to Line Plots Separated by Meter

+

Add Selected Channels to Histograms Separated by Meter (Dropdown)

+

Add Selected Channels to Statistics Tables Separated by Meter (Dropdown)

+ {props.SelectedSet.size === 0 ?

Requires a Selected Channel

: null} +
+
setHover('Group-Line')} onMouseLeave={() => setHover('None')}> + } + ContainerStyle={{ width: '100%' }} + Disabled={props.SelectedSet.size === 0} + Callback={() => addSelectedPlots('Line', channel => channel.ChannelGroup)} + Options={[{ + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Histogram', channel => channel.ChannelGroup) + }, + { + Label: , + Disabled: props.SelectedSet.size === 0, + Callback: () => addSelectedPlots('Statistics', channel => channel.ChannelGroup) + }]} + Size='sm' + /> +
+ +

Add Selected Channels to Line Plots Separated by Channel Group

+

Add Selected Channels to Histograms Separated by Channel Group (Dropdown)

+

Add Selected Channels to Statistics Tables Separated by Channel Group (Dropdown)

+ {props.SelectedSet.size === 0 ?

Requires a Selected Channel

: null} +
+ + +

Add Selected Channel to New Cyclic Histogram Plot

+ {props.SelectedSet.size !== 1 ?

Requires a Single Channel Selection

: null} +