diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Components/TrendMarkerTable.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Components/TrendMarkerTable.tsx index 734a06f9..3940c0d3 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/Components/TrendMarkerTable.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Components/TrendMarkerTable.tsx @@ -33,17 +33,16 @@ interface IProps { Selected: TrendSearch.IMarker, SetSelected: (marker: TrendSearch.IMarker) => void, Height: number, - IsGlobal: boolean + IsGlobal: boolean, + XAxisType?: 'time' | 'value' } const TrendMarkerTable = (props: IProps) => { - const [trendMarkers, setTrendMarkers] = React.useState([]); const [sortField, setSortField] = React.useState('MeterName'); const [ascending, setAscending] = React.useState(true); - const momentFormat = "DD HH:mm:ss.SSS"; - React.useEffect(() => { - setTrendMarkers(_.orderBy(props.Markers, sortField, (ascending ? 'asc' : 'desc'))); + const trendMarkers: TrendSearch.IMarker[] = React.useMemo(() => { + return _.orderBy(props.Markers, sortField, (ascending ? 'asc' : 'desc')); }, [props.Markers, sortField, ascending]); const removeButton = React.useCallback( @@ -117,9 +116,9 @@ const TrendMarkerTable = (props: IProps) => { } else { switch (row.item.type) { case "VeHo": - return (row.item["isHori"] ?? true) ? row.item["value"].toFixed(2) : moment.utc(row.item["value"]).format(momentFormat); + return (row.item["isHori"] ?? true) ? row.item["value"].toFixed(2) : formatXValue(row.item["value"], props.XAxisType); case "Symb": - return `${moment.utc(row.item["xPos"]).format(momentFormat)} | ${row.item["yPos"].toFixed(2)}`; + return `${formatXValue(row.item["xPos"], props.XAxisType)} | ${row.item["yPos"].toFixed(2)}`; default: return "All Events"; } @@ -142,4 +141,7 @@ const TrendMarkerTable = (props: IProps) => { ); } +const momentFormat = "DD HH:mm:ss.SSS"; +const formatXValue = (value: number, xAxisType?: 'time' | 'value') => xAxisType === 'value' ? value.toFixed(2) : moment.utc(value).format(momentFormat); + export default TrendMarkerTable; \ No newline at end of file diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx index 6b36c973..f9320ff3 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/NavBar/TrendDataNavbarButtons.tsx @@ -40,8 +40,32 @@ interface IProps { SetSelectedSet: React.Dispatch>> } +type Hover = 'None' | 'Show' | 'Hide' | 'Cog' | 'Single-Plot-Group' | 'Meter-Plot-Group' | 'Group-Line' | 'Cyclic' | 'Move' | 'Trash' | 'Select' | 'Capture'; + const TrendDataNavbarButtons = (props: IProps) => { - const [hover, setHover] = React.useState<'None' | 'Show' | 'Hide' | 'Cog' | 'Single-Plot-Group' | 'Meter-Plot-Group' | 'Group-Line' | 'Cyclic' | 'Move' | 'Trash' | 'Select' | 'Capture'>('None'); + const [hover, setHover] = React.useState('None'); + + const addSelectedPlots = (type: TrendSearch.IPlotTypes, groupBy?: (channel: TrendSearch.ITrendChannel) => string) => { + if (props.SelectedSet.size === 0) return; + + const selectedChannels = props.TrendChannels.filter(channel => props.SelectedSet.has(channel.ID)); + const channelGroups = groupBy == null ? [selectedChannels] : selectedChannels.reduce((groups, channel) => { + const group = groups.find(channelList => groupBy(channelList[0]) === groupBy(channel)); + if (group == null) + groups.push([channel]); + else + group.push(channel); + return groups; + }, []); + + props.AddNewCharts(channelGroups.map(channels => ({ + TimeFilter: props.TimeFilter, + Type: type, + Channels: channels, + ID: CreateGuid(), + PlotFilter: props.LinePlot + }))); + }; if (!props.ShowNav) return ( @@ -61,272 +85,226 @@ const TrendDataNavbarButtons = (props: IProps) => {
- - - 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 - }]); - } - }]} - Size='sm' - /> -
- -

Add All Selected Channels to Single Line Plot

-

Add All Selected Channels to Single Histogram (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 - }))); - } - }]} - Size='sm' - /> -
- -

Add Selected Channels to Line Plots Separated by Meter

-

Add Selected Channels to Histograms 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} +
diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/HistogramPlotSettingsTab.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/HistogramPlotSettingsTab.tsx new file mode 100644 index 00000000..5c8916cb --- /dev/null +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/HistogramPlotSettingsTab.tsx @@ -0,0 +1,30 @@ +//****************************************************************************************************** +// HistogramPlotSettingsTab.tsx - 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, the subject 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: +// ---------------------------------------------------------------------------------------------------- +// 08/03/26 - Preston Crawford +// Generated original version of source code. +// +//****************************************************************************************************** +import React from 'react'; +import { IPlotSettingsProps, PlotSettingsTab } from './PlotSettingsTab'; + +const HistogramPlotSettingsTab = React.memo((props: IPlotSettingsProps) => ( + +)); + +export { HistogramPlotSettingsTab }; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/MarkerTab.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/MarkerTab.tsx index 6c694504..c0de9d63 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/MarkerTab.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/MarkerTab.tsx @@ -37,7 +37,8 @@ export interface IMarkerSettingsProps { EventSettings: TrendSearch.EventMarkerSettings, SetEventSettings: (setting: TrendSearch.EventMarkerSettings) => void, DisplayEventSettings: boolean, - IsGlobalSettings: boolean + IsGlobalSettings: boolean, + XAxisType?: 'time' | 'value' } const EventOptions = [{ Label: "Vertical Lines", Value: "Event-Vert" }, { Label: "Custom Symbols", Value: "Event-Symb" }]; // Loading all SVGIcons into the options menue @@ -125,10 +126,11 @@ const MarkerTab = React.memo((props: IMarkerSettingsProps) => { Record={currentMarker as TrendSearch.ISymbolic} Label={'Infobox Opacity'} Field={'opacity'} Setter={state => applyToMarker(state, editFromArray)} Feedback={"Opacity must be between 0 and 1"} Valid={() => { return (currentMarker['opacity'] <= 1 && currentMarker['opacity'] >= 0); }} /> - Record={currentMarker as TrendSearch.ISymbolic} Label={'Timestamp Format'} Field={'format'} Setter={state => applyToMarker(state, editFromArray)} Feedback={"Must be a valid timestamp format"} Valid={() => { - // TODO: This must be a valid string, something to check this should be added to helper functions in gemstone soon - return true; - }} /> + {props.XAxisType === 'value' ? null : + Record={currentMarker as TrendSearch.ISymbolic} Label={'Timestamp Format'} Field={'format'} Setter={state => applyToMarker(state, editFromArray)} Feedback={"Must be a valid timestamp format"} Valid={() => { + // TODO: This must be a valid string, something to check this should be added to helper functions in gemstone soon + return true; + }} />} Record={currentMarker as TrendSearch.ISymbolic} Label={'Note Text Size (em)'} Field={'fontSize'} Setter={state => applyToMarker(state, editFromArray)} Feedback={"Font size must be a positive number"} Valid={() => { return currentMarker['fontSize'] > 0; }} /> @@ -208,7 +210,7 @@ const MarkerTab = React.memo((props: IMarkerSettingsProps) => { : <> } - applyToMarker(marker, removeFromArray)} Selected={currentMarker} SetSelected={setCurrentMarker} /> diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/PlotSettingsTab.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/PlotSettingsTab.tsx index a4d316e8..fb4c88d0 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/PlotSettingsTab.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/PlotSettingsTab.tsx @@ -34,7 +34,9 @@ export interface IPlotSettingsProps { Plot: TrendSearch.ITrendPlot, SetPlot: (record: TrendSearch.ITrendPlot) => void, SetConfirmDisabled: (record: boolean) => void, - IsGlobalSettings: boolean + IsGlobalSettings: boolean, + ShowEvents?: boolean, + ShowAxisLimits?: boolean } interface AxisLimits { LeftUpper: number, LeftLower: number, RightUpper: number, RightLower: number } @@ -118,7 +120,8 @@ const PlotSettingsTab = React.memo((props: IPlotSettingsProps) => { } function isValid(): boolean { - return validateTrendPlot('Height') && validateTrendPlot('Width') && validateLimit("LeftUpper") && validateLimit("RightUpper"); + const limitsAreValid = !(props.ShowAxisLimits ?? true) || (validateLimit("LeftUpper") && validateLimit("RightUpper")); + return validateTrendPlot('Height') && validateTrendPlot('Width') && limitsAreValid; } return ( @@ -193,9 +196,11 @@ const PlotSettingsTab = React.memo((props: IPlotSettingsProps) => {
Record={props.Plot} Label='Use Metric Abbreviation' Field='Metric' Setter={props.SetPlot} />
-
- Record={props.Plot} Label='Display Events' Field='ShowEvents' Setter={props.SetPlot} /> -
+ {(props.ShowEvents ?? true) ? +
+ Record={props.Plot} Label='Display Events' Field='ShowEvents' Setter={props.SetPlot} /> +
+ : null} @@ -203,36 +208,38 @@ const PlotSettingsTab = React.memo((props: IPlotSettingsProps) => { props.SetPlot({ ...props.Plot, TimeFilter: fromGemstoneFilter(start, end, unit, duration) })} dateTimeSetting={dateTimeSetting} timeZone={timeZone} /> -
- Axis Limits: -
-
- Record={props.Plot} Setter={props.SetPlot} Field='AxisZoom' Options={axisOptions} Label='' - EmptyOption={false} Help={"Selects range of plot."} - /> -
-
-
-
- Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} - Label='Left Axis Lower' Field='LeftLower' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'} /> -
-
- Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} - Label='Left Axis Upper' Field='LeftUpper' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> + {(props.ShowAxisLimits ?? true) ? +
+ Axis Limits: +
+
+ Record={props.Plot} Setter={props.SetPlot} Field='AxisZoom' Options={axisOptions} Label='' + EmptyOption={false} Help={"Selects range of plot."} + /> +
-
-
-
- Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} - Label='Right Axis Lower' Field='RightLower' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> +
+
+ Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} + Label='Left Axis Lower' Field='LeftLower' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'} /> +
+
+ Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} + Label='Left Axis Upper' Field='LeftUpper' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> +
-
- Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} - Label='Right Axis Upper' Field='RightUpper' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> +
+
+ Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} + Label='Right Axis Lower' Field='RightLower' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> +
+
+ Record={limits} Setter={setPlotLimits} Valid={validateLimit} Feedback={limitFeedback} + Label='Right Axis Upper' Field='RightUpper' Type='integer' Disabled={props.Plot.AxisZoom !== 'Manual'}/> +
-
-
+ + : null} ); diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/SettingsModal.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/SettingsModal.tsx index 42392e18..58b0683f 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/SettingsModal.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/SettingsModal.tsx @@ -187,6 +187,7 @@ const SettingsModal = React.memo((props: IOverlayProps) => { SetEventSettings={setEventBuffer} DisplayEventSettings={plotBuffer?.ShowEvents ?? false} IsGlobalSettings={false} + XAxisType={plotBuffer?.Type === 'Histogram' ? 'value' : 'time'} /> diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendData.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendData.tsx index 62bef734..6415d19e 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendData.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendData.tsx @@ -24,7 +24,7 @@ import React from 'react'; import _ from 'lodash'; import TrendSearchNavbar from './NavBar/TrendDataNavbar'; -import TrendPlot from './TrendPlot/TrendPlot'; +import TrendPlot from './TrendPlot/TrendWidgetWrapper'; import { TrendSearch } from '../../global'; import AllSettingsModal from './Settings/AllSettingsModal'; import { SelectTrendDataSettings } from '../../Store/SettingsSlice'; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx index 9e8209b6..3613821e 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx @@ -36,7 +36,7 @@ import { parseTrendDataResponse, requestTrendData } from '../Utils/TrendDataRequ const binCount = 10; -const seriesTypes = ['Minimum', 'Average', 'Maximum'] as const; +const seriesTypes = ['Minimum', 'Average', 'Maximum']; type SeriesType = typeof seriesTypes[number]; @@ -182,6 +182,7 @@ const Histogram = React.memo((props: ITrendWidgetProps) => { width={series.Settings.Width} /> )} + {props.Overlays} {props.Controls} Selected Channels have no finite data for the selected Time Window. diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx new file mode 100644 index 00000000..854b9733 --- /dev/null +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx @@ -0,0 +1,181 @@ +//****************************************************************************************************** +// Statistics.tsx - 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 React from 'react'; +import { Application } from '@gpa-gemstone/application-typings'; +import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; +import { Button } from '@gpa-gemstone/react-graph'; +import { ToolTip } from '@gpa-gemstone/react-forms'; +import { LoadingIcon } from '@gpa-gemstone/react-interactive'; +import { Column, Table } from '@gpa-gemstone/react-table'; +import { orderBy } from 'lodash'; +import { TrendSearch } from '../../../../global'; +import GraphError from '../GraphError'; +import { ITrendWidgetProps } from '../TrendWidgetRegistry'; +import { parseTrendDataResponse, requestTrendData } from '../../Utils/TrendDataRequest'; +import { buildStatisticsRows, getChannelTag, IStatisticsRow, statisticSeriesTypes } from './StatisticsData'; + +type StatisticsSortKey = Exclude; + +const numericColumns: { Field: Exclude, Label: string }[] = [ + { Field: 'Min', Label: 'Min' }, + { Field: 'CP005', Label: 'CP00.5' }, + { Field: 'CP01', Label: 'CP01' }, + { Field: 'CP05', Label: 'CP05' }, + { Field: 'CP25', Label: 'CP25' }, + { Field: 'Avg', Label: 'Avg' }, + { Field: 'CP50', Label: 'CP50' }, + { Field: 'CP75', Label: 'CP75' }, + { Field: 'CP95', Label: 'CP95' }, + { Field: 'CP99', Label: 'CP99' }, + { Field: 'CP995', Label: 'CP99.5' }, + { Field: 'Max', Label: 'Max' }, + { Field: 'Count', Label: 'Count' }, + { Field: 'StdDev', Label: 'Std Dev' } +]; + +const statisticColumnWidth = 150; +const numericColumnWidth = 100; + +const tableMinimumWidth = statisticColumnWidth + numericColumnWidth * numericColumns.length; + +/** Displays summary statistics for each selected minimum, average, and maximum channel series. */ +const Statistics = React.memo((props: ITrendWidgetProps) => { + const [points, setPoints] = React.useState([]); + const [status, setStatus] = React.useState('uninitiated'); + const [sortKey, setSortKey] = React.useState('Statistic'); + const [ascending, setAscending] = React.useState(true); + const [hover, setHover] = React.useState(false); + const channelIDs = Array.from(new Set((props.ChannelInfo ?? []).map(info => info?.Channel?.ChannelID).filter(isChannelID))).sort((left, right) => left - right); + const channelKey = channelIDs.join(','); + const rows = React.useMemo(() => buildStatisticsRows(points, props.ChannelInfo, props.PlotFilter), [points, props.ChannelInfo, props.PlotFilter]); + const sortedRows = React.useMemo(() => sortRows(rows, sortKey, ascending), [rows, sortKey, ascending]); + const hasMissingData = status === 'idle' && rows.some(row => row.Count === 0); + + React.useEffect(() => { + if (props.TimeFilter == null || channelKey.length === 0) { + setPoints([]); + setStatus('idle'); + return; + } + + setPoints([]); + setStatus('loading'); + const handle = requestTrendData(channelIDs, props.TimeFilter).done((response: string) => { + const responsePoints = parseTrendDataResponse(response); + setPoints(responsePoints); + props.SetChannelInfo((props.ChannelInfo ?? []).map(channel => { + const tag = getChannelTag(channel?.Channel?.ChannelID); + const channelPoints = tag == null ? [] : responsePoints.filter(point => typeof point?.Tag === 'string' && point.Tag.toLowerCase() === tag); + const settings = { ...(channel.Settings ?? {}) } as TrendSearch.ILineSeriesSettings; + statisticSeriesTypes.forEach(type => { + if (settings[type] != null) + settings[type] = { ...settings[type], HasData: channelPoints.some(point => Number.isFinite(point[type])) }; + }); + return { ...channel, Settings: settings }; + })); + setStatus('idle'); + }).fail((_request, requestStatus) => { + if (requestStatus !== 'abort') setStatus('error'); + }); + return () => handle.abort(); + }, [channelKey, props.TimeFilter]); + + if (status === 'error') + return {props.Controls}; + + return ( +
+ +
+
+

+ {props.Title ?? ''} + {hasMissingData ? + setHover(true)} onMouseLeave={() => setHover(false)}> + + + : null} +

+
+ {React.Children.map(props.Controls, element => { + if (!React.isValidElement(element) || (element as React.ReactElement).type !== Button) return null; + return ( + + ); + })} +
+
+
+ + 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..49e77ef7 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts @@ -25,8 +25,10 @@ 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 { HistogramPlotSettingsTab } from '../Settings/OverlayTabs/HistogramPlotSettingsTab'; import { MarkerTab } from '../Settings/OverlayTabs/MarkerTab'; import type { IMarkerSettingsProps } from '../Settings/OverlayTabs/MarkerTab'; import { CyclicChannelTab } from '../Settings/OverlayTabs/ChannelTabs/CyclicChannelTab'; @@ -108,8 +110,12 @@ export const TrendWidgetRegistry: Record { // Plot Markers const [symbolicMarkers, setSymbolicMarkers] = React.useState([]); const [horiVertMarkers, setHoriVertMarkers] = React.useState([]); - const [mousePosition, setMousePosition] = React.useState<{ x: number, y: number }>({ x: 0, y: 0 }); + const [mousePosition, setMousePosition] = React.useState<{ x: number, yLeft: number, yRight: number }>({ x: 0, yLeft: 0, yRight: 0 }); // Event Information const [eventMarkers, setEventMarkers] = React.useState([]); @@ -247,7 +247,8 @@ const TrendPlot = (props: IContainerProps) => { 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') { @@ -495,20 +497,22 @@ const TrendPlot = (props: IContainerProps) => { // Buttons added to the plots const closeButton = ( - ); + }} + > + {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') => -