diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 00000000..8408be2f --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,3 @@ +## React/TSX conventions + +Before creating, editing, refactoring, or reviewing any `.tsx` file, read and follow `docs/conventions/tsx-conventions.md`. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eef4bd20 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/ChannelTabs/HistogramChannelTab.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/ChannelTabs/HistogramChannelTab.tsx index 414c4ae3..0ebc7253 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/ChannelTabs/HistogramChannelTab.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/Settings/OverlayTabs/ChannelTabs/HistogramChannelTab.tsx @@ -111,9 +111,11 @@ const HistogramChannelEditor = (props: IChannelEditorProps) => { const withCumulativeDefaults = (settings: TrendSearch.IHistogramSettings): TrendSearch.IHistogramSettings => ({ ...settings, + Enabled: settings.Enabled ?? true, ShowCumulativeProbability: settings.ShowCumulativeProbability ?? true, CumulativeProbabilityColor: settings.CumulativeProbabilityColor ?? settings.Color, - CumulativeProbabilityLabel: settings.CumulativeProbabilityLabel ?? `${settings.Label} Cumulative Probability` + CumulativeProbabilityLabel: settings.CumulativeProbabilityLabel ?? `${settings.Label} Cumulative Probability`, + CumulativeProbabilityEnabled: settings.CumulativeProbabilityEnabled ?? true }); const HistogramChannelTab = (props: IChannelSettingsProps) => ; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/CyclicHistogram.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/CyclicHistogram.tsx index c96594e6..347b9172 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/CyclicHistogram.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/CyclicHistogram.tsx @@ -47,17 +47,17 @@ const CyclicHistogram = React.memo((props: ITrendWidgetProps) => { const channelInfo = props.ChannelInfo?.[0] ?? null; // Graph Consts const [timeLimits, setTimeLimits] = React.useState<[number, number]>([0, 1]); - const [chartData, setChartData] = React.useState(null); + const [chartData, setChartData] = React.useState(null); const [graphStatus, setGraphStatus] = React.useState('uninitiated'); const [hover, setHover] = React.useState(false); - const [barColor, setBarColor] = React.useState<{ Hue: number, Saturation: number }>(null); - const [metaData, setMetaData] = React.useState(null); + const [barColor, setBarColor] = React.useState<{ Hue: number, Saturation: number } | null>(null); + const [metaData, setMetaData] = React.useState(null); // Height mangement const [plotHeight, setPlotHeight] = React.useState(props.Height); const [extraLegendHeight, setExtraLegendHeight] = React.useState(0); const titleRef = React.useRef(null); const { offsetHeight: titleHeight } = useGetContainerPosition(titleRef); - const oldValues = React.useRef<{ ChannelInfo: TrendSearch.ISeriesSettings, TimeFilter: SEBrowser.IReportTimeFilter }>({ ChannelInfo: null, TimeFilter: null }); + const oldValues = React.useRef<{ ChannelInfo: TrendSearch.ISeriesSettings | null, TimeFilter: SEBrowser.IReportTimeFilter | null }>({ ChannelInfo: null, TimeFilter: null }); const trendDatasettings = useAppSelector(SelectTrendDataSettings); const generalSettings = useAppSelector(SelectGeneralSettings); @@ -197,12 +197,40 @@ const CyclicHistogram = React.memo((props: ITrendWidgetProps) => { : null } - captureCallback(0)} cursorOverride={props.Cursor} snapMouse={trendDatasettings.MarkerSnapping} - legend={trendDatasettings.LegendDisplay} useMetricFactors={props.Metric ?? false} holdMenuOpen={!trendDatasettings.StartWithOptionsClosed} showDateOnTimeAxis={false} limitZoom={true} - Tlabel={props.XAxisLabel} Ylabel={[props.YLeftLabel]} showMouse={props.MouseHighlight} yDomain={props.AxisZoom} defaultYdomain={props.DefaultZoom}> + captureCallback(0)} + cursorOverride={props.Cursor} + snapMouse={trendDatasettings.MarkerSnapping} + legend={trendDatasettings.LegendDisplay} + useMetricFactors={props.Metric ?? false} + holdMenuOpen={!trendDatasettings.StartWithOptionsClosed} + showDateOnTimeAxis={false} + limitZoom={true} + Tlabel={props.XAxisLabel} + Ylabel={[props.YLeftLabel]} + showMouse={props.MouseHighlight} + yDomain={props.AxisZoom} + defaultYdomain={props.DefaultZoom} + > {(chartData?.Series?.length == null || chartData.Series.length === 0 || barColor === null) ? null : - + } {props.Overlays} {props.Controls} @@ -210,7 +238,8 @@ const CyclicHistogram = React.memo((props: ITrendWidgetProps) => { Selected Channel has no Data for the selected Time Window. - ); + + ); }); export { CyclicHistogram }; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/GraphError.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/GraphError.tsx index d9617c55..b7cca226 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/GraphError.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/GraphError.tsx @@ -26,12 +26,10 @@ import { Button } from '@gpa-gemstone/react-graph'; interface IProps { Title?: string, - Height: number, - children: React.ReactNode + Height: number } -const GraphError = React.memo((props: IProps) => { - +const GraphError = (props: React.PropsWithChildren) => { return ( <> {props.Title !== undefined ? @@ -54,11 +52,13 @@ const GraphError = React.memo((props: IProps) => { onClick={() => element.props?.onClick?.()}> {element} - ); + + ); return null; })} - ); -}); + + ); +}; export default GraphError; \ No newline at end of file diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx index 3613821e..78a042a5 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx @@ -32,6 +32,7 @@ import { SelectGeneralSettings, SelectTrendDataSettings } from '../../../Store/S import { useAppSelector } from '../../../hooks'; import GraphError from './GraphError'; import { ITrendWidgetProps } from './TrendWidgetRegistry'; +import { CsvRow, downloadCsv } from './TrendCsv'; import { parseTrendDataResponse, requestTrendData } from '../Utils/TrendDataRequest'; const binCount = 10; @@ -44,12 +45,27 @@ type SeriesSettingsWithChannel = TrendSearch.ISeriesSettings & { Channel: TrendS interface IHistogramSeries { ID: string, + ChannelID: string, + SeriesKey: string, Color: string, Percentages: number[], Label: string, Settings: TrendSearch.IHistogramSettings } +interface IHistogramBinnedSeries { + ID: string, + ChannelID: string, + SeriesKey: string, + Percentages: number[] +} + +interface IHistogramBinnedData { + BinWidth: number, + Domain: [number, number], + Series: IHistogramBinnedSeries[] +} + interface IHistogramData { BinWidth: number, Domain: [number, number], @@ -73,7 +89,12 @@ const Histogram = React.memo((props: ITrendWidgetProps) => { const width = props.Width ?? 0; const channelIDs = (props.ChannelInfo ?? []).map(info => info?.Channel?.ChannelID).filter(isChannelID); const channelKey = channelIDs.join(','); - const data = React.useMemo(() => buildHistogramData(points, props.ChannelInfo, props.PlotFilter), [points, props.ChannelInfo, props.PlotFilter]); + const binningKey = getHistogramBinningKey(props.ChannelInfo, props.PlotFilter); + const binnedData = React.useMemo(() => buildHistogramData(points, props.ChannelInfo, props.PlotFilter), [points, binningKey]); + const data = React.useMemo(() => applyHistogramSettings(binnedData, props.ChannelInfo), [binnedData, props.ChannelInfo]); + const enabledVisualizationCount = React.useMemo(() => data?.Series.reduce((count, series) => + count + (series.Settings.Enabled ?? true ? 1 : 0) + + ((series.Settings.ShowCumulativeProbability ?? true) && (series.Settings.CumulativeProbabilityEnabled ?? true) ? 1 : 0), 0) ?? 0, [data]); const plotHeight = Math.max(0, height - titleHeight - 5); // Load the samples for the selected channels and time window. @@ -115,6 +136,33 @@ const Histogram = React.memo((props: ITrendWidgetProps) => { return props.ID; }, [props.ID, props.SetExtraSpace]); + const setHistogramEnabled = React.useCallback(( + channelID: string, + seriesKey: string, + field: 'Enabled' | 'CumulativeProbabilityEnabled', + action: React.SetStateAction + ) => { + props.SetChannelInfo(currentSettings => currentSettings.map(channel => { + if (channel.Channel?.ID !== channelID) return channel; + const settings = channel.Settings as TrendSearch.IHistogramSeriesSettings; + const seriesSettings = settings[seriesKey]; + if (seriesSettings == null) return channel; + const currentEnabled = seriesSettings[field] ?? true; + const enabled = typeof action === 'function' ? action(currentEnabled) : action; + return { + ...channel, + Settings: { + ...settings, + [seriesKey]: { ...seriesSettings, [field]: enabled } + } + }; + })); + }, [props.SetChannelInfo]); + + const exportCsv = React.useCallback(() => { + if (data != null) downloadCsv(buildHistogramCsvRows(data), props.Title); + }, [data, props.Title]); + if (graphStatus === 'error') return {props.Controls}; @@ -144,6 +192,7 @@ const Histogram = React.memo((props: ITrendWidgetProps) => { onSelect={props.OnSelect} onCapture={captureCallback} onCaptureComplete={() => captureCallback(0)} + onDataInspect={graphStatus === 'idle' && data != null && enabledVisualizationCount > 0 ? exportCsv : undefined} cursorOverride={props.Cursor} snapMouse={trendDataSettings.MarkerSnapping} legend={trendDataSettings.LegendDisplay} @@ -156,7 +205,12 @@ const Histogram = React.memo((props: ITrendWidgetProps) => { yDomain={props.AxisZoom} > {data?.Series.map((series, seriesIndex) => - + setHistogramEnabled(series.ChannelID, series.SeriesKey, 'Enabled', enabled)} + > {series.Percentages.map((percentage, binIndex) => { legend={series.Settings.CumulativeProbabilityLabel ?? `${series.Label} Cumulative Probability`} axis="right" width={series.Settings.Width} + enabled={series.Settings.CumulativeProbabilityEnabled ?? true} + setEnabled={enabled => setHistogramEnabled(series.ChannelID, series.SeriesKey, 'CumulativeProbabilityEnabled', enabled)} /> )} {props.Overlays} @@ -212,7 +268,7 @@ const getSeriesPlotted = (plotFilter?: IMultiCheckboxOption[] | null): SeriesTyp * Each returned series contains the percentage of its finite samples falling into each bin. */ const buildHistogramData = (points?: TrendSearch.IPQData[] | null, channelInfo?: TrendSearch.ISeriesSettings[] | null, - plotFilter?: IMultiCheckboxOption[] | null): IHistogramData | null => { + plotFilter?: IMultiCheckboxOption[] | null): IHistogramBinnedData | null => { const channels = (channelInfo ?? []).filter(hasChannel); const validPoints = (points ?? []).filter(point => typeof point?.Tag === 'string'); const plottedSeries = getSeriesPlotted(plotFilter); @@ -253,10 +309,9 @@ const buildHistogramData = (points?: TrendSearch.IPQData[] | null, channelInfo?: }); return { ID: `${channel.Channel.ID}_${type}`, - Color: seriesSetting.Color, - Percentages: values.length === 0 ? counts : counts.map(count => 100 * count / values.length), - Label: seriesSetting.Label, - Settings: seriesSetting + ChannelID: channel.Channel.ID, + SeriesKey: type, + Percentages: values.length === 0 ? counts : counts.map(count => 100 * count / values.length) }; }); }); @@ -264,6 +319,30 @@ const buildHistogramData = (points?: TrendSearch.IPQData[] | null, channelInfo?: return { BinWidth: binWidth, Domain: [minimum, maximum], Series: series }; }; +/** Applies current labels, colors, and visibility without rebuilding histogram bins. */ +const applyHistogramSettings = (data?: IHistogramBinnedData | null, + channelInfo?: TrendSearch.ISeriesSettings[] | null): IHistogramData | null => { + if (data == null) return null; + const series = data.Series.flatMap(binnedSeries => { + const channel = (channelInfo ?? []).find(channel => channel.Channel?.ID === binnedSeries.ChannelID); + const settings = (channel?.Settings as TrendSearch.IHistogramSeriesSettings | undefined)?.[binnedSeries.SeriesKey]; + if (settings == null) return []; + return [{ ...binnedSeries, Color: settings.Color, Label: settings.Label, Settings: settings }]; + }); + return { ...data, Series: series }; +}; + +/** Builds a memoization key from bin-affecting settings so display-only changes do not rebuild histogram bins. */ +const getHistogramBinningKey = (channelInfo?: TrendSearch.ISeriesSettings[] | null, + plotFilter?: IMultiCheckboxOption[] | null): string => { + const channels = (channelInfo ?? []).filter(hasChannel).map(channel => { + const settings = channel.Settings as TrendSearch.IHistogramSeriesSettings; + const configuredSeries = seriesTypes.filter(type => settings?.[type] != null).join(','); + return `${channel.Channel.ID}:${configuredSeries}`; + }).join('|'); + return `${channels};${getSeriesPlotted(plotFilter).join(',')}`; +}; + /** Returns an empirical cumulative distribution at each histogram bin boundary. */ const getCumulativeProbability = (percentages: number[], minimum: number, binWidth: number): [number, number][] => { let cumulative = 0; @@ -273,4 +352,29 @@ const getCumulativeProbability = (percentages: number[], minimum: number, binWid })); }; +/** Builds CSV rows from the shared bins and series values displayed by the histogram. */ +export const buildHistogramCsvRows = (data: IHistogramData): CsvRow[] => { + const barSeries = data.Series.filter(series => series.Settings.Enabled ?? true); + const cumulativeSeries = data.Series.filter(series => + (series.Settings.ShowCumulativeProbability ?? true) && (series.Settings.CumulativeProbabilityEnabled ?? true) + ); + const cumulativeValues = cumulativeSeries.map(series => + getCumulativeProbability(series.Percentages, data.Domain[0], data.BinWidth).slice(1).map(point => point[1]) + ); + return [ + [ + 'Bin Start', + 'Bin End', + ...barSeries.map(series => series.Label), + ...cumulativeSeries.map(series => series.Settings.CumulativeProbabilityLabel ?? `${series.Label} Cumulative Probability`) + ], + ...Array.from({ length: binCount }, (_, binIndex) => [ + data.Domain[0] + binIndex * data.BinWidth, + data.Domain[0] + (binIndex + 1) * data.BinWidth, + ...barSeries.map(series => series.Percentages[binIndex]), + ...cumulativeValues.map(values => values[binIndex]) + ]) + ]; +}; + export { Histogram }; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraph.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraph.tsx index b5755b67..a5826c09 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraph.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraph.tsx @@ -36,6 +36,8 @@ import { useGetContainerPosition } from '@gpa-gemstone/helper-functions'; import { ITrendWidgetProps } from './TrendWidgetRegistry'; import { serverFormat } from '../Utils/Constants'; import { getTrendTimeWindow, parseTrendDataResponse, requestTrendData } from '../Utils/TrendDataRequest'; +import { buildLineGraphCsvRows, IExportSeries } from './LineGraphCsv'; +import { downloadCsv } from './TrendCsv'; interface IChartData { [key: string]: [number, number][] @@ -67,7 +69,7 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { // This represents data we already have and still need (only makes sense if we aren't changing our time window) keptOldData = allChartData; keptOldData.forEach((_, channelID) => { - if (props.ChannelInfo.findIndex(channel => channel.Channel.ChannelID === Number("0x" + channelID)) < 0) + if (props.ChannelInfo.findIndex(channel => channel?.Channel?.ChannelID === Number("0x" + channelID)) < 0) keptOldData.delete(channelID); } ); @@ -99,6 +101,44 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { return props.ID; }, [props.ID, setExtraLegendHeight, props.SetExtraSpace]); + const setLineEnabled = React.useCallback((channelID: string, seriesKey: string, action: React.SetStateAction) => { + props.SetChannelInfo(currentSettings => currentSettings.map(series => { + if (series?.Channel?.ID !== channelID) return series; + const settings = series.Settings as TrendSearch.ILineSeriesSettings; + const enabled = typeof action === 'function' ? action(settings[seriesKey].Enabled ?? true) : action; + return { + ...series, + Settings: { + ...settings, + [seriesKey]: { ...settings[seriesKey], Enabled: enabled } + } + }; + })); + }, [props.SetChannelInfo]); + + const exportSeries = React.useMemo(() => { + if (graphStatus !== 'idle' || props.ChannelInfo == null) return [] as IExportSeries[]; + const seriesToExport: IExportSeries[] = []; + props.ChannelInfo.forEach(series => { + const channelSettings = series.Settings as TrendSearch.ILineSeriesSettings; + const dataKey = [...allChartData.keys()].find(key => series?.Channel?.ChannelID === Number('0x' + key)); + if(dataKey == null) return; + const chartData = allChartData.get(dataKey); + if (chartData == null) return; + Object.keys(chartData).forEach(key => { + const settings = channelSettings[key]; + const isPlotted = props.PlotFilter.find(option => option.Value === key)?.Selected ?? true; + if (settings?.HasData && (settings.Enabled ?? true) && isPlotted && chartData[key].length > 0) + seriesToExport.push({ Label: settings.Label, Data: chartData[key] }); + }); + }); + return seriesToExport; + }, [allChartData, graphStatus, props.ChannelInfo, props.PlotFilter]); + + const exportCsv = React.useCallback(() => { + downloadCsv(buildLineGraphCsvRows(exportSeries), props.Title); + }, [exportSeries, props.Title]); + function loadTrendData(channels: number[], cachedData: Map, timeFilter: SEBrowser.IReportTimeFilter): JQuery.jqXHR { setGraphStatus('loading'); return requestTrendData(channels, timeFilter).done((data: string) => { @@ -107,9 +147,11 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { // Todo: Handle alternate Series Types if (cachedData.has(point.Tag)) { const chartData = cachedData.get(point.Tag); - chartData.Minimum.push([timeStamp, point.Minimum]); - chartData.Average.push([timeStamp, point.Average]); - chartData.Maximum.push([timeStamp, point.Maximum]); + if(chartData != null) { + chartData.Minimum.push([timeStamp, point.Minimum]); + chartData.Average.push([timeStamp, point.Average]); + chartData.Maximum.push([timeStamp, point.Maximum]); + } } else { const chartData: IChartData = { Minimum: [[timeStamp, point.Minimum]], @@ -130,6 +172,7 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { const channeldata = cachedData.get(channelID); const newSettings = _.cloneDeep(setting.Settings) as TrendSearch.ILineSeriesSettings; Object.keys(newSettings).forEach(key => { + newSettings[key].Enabled ??= true; // Hide all if no data if (channeldata == null) newSettings[key].HasData = false; // Hide min/max if no data @@ -176,6 +219,7 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { onSelect={props.OnSelect} onCapture={captureCallback} onCaptureComplete={() => captureCallback(0)} + onDataInspect={graphStatus === 'idle' && exportSeries.length > 0 ? exportCsv : undefined} cursorOverride={props.Cursor} snapMouse={trendDatasettings.MarkerSnapping} legend={trendDatasettings.LegendDisplay} @@ -209,6 +253,8 @@ const LineGraph = React.memo((props: ITrendWidgetProps) => { legend={channelSetting[key].Label} axis={channelSetting[key].Axis} width={channelSetting[key].Width} + enabled={channelSetting[key].Enabled ?? true} + setEnabled={(enabled) => setLineEnabled(series.Channel.ID, key, enabled)} /> ); } diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraphCsv.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraphCsv.ts new file mode 100644 index 00000000..09873d42 --- /dev/null +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/LineGraphCsv.ts @@ -0,0 +1,48 @@ +//****************************************************************************************************** +// LineGraphCsv.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, 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. +// +//****************************************************************************************************** + +import _ from 'lodash'; +import { CsvRow } from './TrendCsv'; + +export interface IExportSeries { + Label: string, + Data: [number, number][] +} + +/** Builds CSV rows from the line series currently displayed by the plot. */ +export function buildLineGraphCsvRows(series: IExportSeries[]): CsvRow[] { + const valueMaps = series.map(item => { + const values = new Map(); + item.Data.forEach(([timestamp, value]) => { + if (Number.isFinite(timestamp)) + values.set(timestamp, value); + }); + return values; + }); + const timestamps = _.sortBy(Array.from(new Set(valueMaps.flatMap(values => Array.from(values.keys()))))); + return [ + ['Timestamp', ...series.map(item => item.Label)], + ...timestamps.map(timestamp => [ + new Date(timestamp).toISOString(), + ...valueMaps.map(values => { + const value = values.get(timestamp); + return value != null && Number.isFinite(value) ? value.toString() : ''; + }) + ]) + ]; +} diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx index 854b9733..650a75a6 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/Statistics.tsx @@ -31,10 +31,12 @@ import { orderBy } from 'lodash'; import { TrendSearch } from '../../../../global'; import GraphError from '../GraphError'; import { ITrendWidgetProps } from '../TrendWidgetRegistry'; +import { CsvRow, downloadCsv } from '../TrendCsv'; import { parseTrendDataResponse, requestTrendData } from '../../Utils/TrendDataRequest'; import { buildStatisticsRows, getChannelTag, IStatisticsRow, statisticSeriesTypes } from './StatisticsData'; type StatisticsSortKey = Exclude; +type StatisticsTooltip = 'warning' | 'export' | 'remove' | null; const numericColumns: { Field: Exclude, Label: string }[] = [ { Field: 'Min', Label: 'Min' }, @@ -64,7 +66,7 @@ const Statistics = React.memo((props: ITrendWidgetProps) => { 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 [tooltip, setTooltip] = React.useState(null); 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]); @@ -100,6 +102,10 @@ const Statistics = React.memo((props: ITrendWidgetProps) => { return () => handle.abort(); }, [channelKey, props.TimeFilter]); + const exportCsv = React.useCallback(() => { + downloadCsv(buildStatisticsCsvRows(sortedRows), props.Title); + }, [props.Title, sortedRows]); + if (status === 'error') return {props.Controls}; @@ -111,17 +117,35 @@ const Statistics = React.memo((props: ITrendWidgetProps) => {

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

+ {status === 'idle' && sortedRows.length > 0 ? + + : null} {React.Children.map(props.Controls, element => { if (!React.isValidElement(element) || (element as React.ReactElement).type !== Button) return null; return ( - ); @@ -166,7 +190,15 @@ const Statistics = React.memo((props: ITrendWidgetProps) => { )}
- Some selected Statistics have no finite data for the selected Time Window. + + Some selected Statistics have no finite data for the selected Time Window. + + + Export statistics as CSV. + + + Remove plot. + ); }); @@ -178,4 +210,10 @@ const formatValue = (value: number | null): string => value == null ? '—' : va const sortRows = (rows: IStatisticsRow[], sortKey: StatisticsSortKey, ascending: boolean): IStatisticsRow[] => orderBy(rows, [row => row[sortKey] == null, sortKey], ['asc', ascending ? 'asc' : 'desc']); +/** Builds CSV rows in the same column order and sort order as the displayed statistics table. */ +export const buildStatisticsCsvRows = (rows: IStatisticsRow[]): CsvRow[] => [ + ['Statistic', ...numericColumns.map(column => column.Label)], + ...rows.map(row => [row.Statistic, ...numericColumns.map(column => formatValue(row[column.Field]))]) +]; + export { Statistics }; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts index 30b16ef5..976a3361 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Statistics/StatisticsData.ts @@ -20,6 +20,7 @@ // Generated original version of source code. // //****************************************************************************************************** +import { sortBy } from 'lodash'; import type { IMultiCheckboxOption, TrendSearch } from '../../../../global'; export const statisticSeriesTypes = ['Minimum', 'Average', 'Maximum'] as const; @@ -47,7 +48,7 @@ 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 sortedValues = sortBy(values.filter(Number.isFinite)); const count = sortedValues.length; if (count === 0) return emptyStatistics; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendCsv.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendCsv.ts new file mode 100644 index 00000000..80f20d73 --- /dev/null +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendCsv.ts @@ -0,0 +1,53 @@ +//****************************************************************************************************** +// TrendCsv.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, 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. +// +//****************************************************************************************************** + +export type CsvValue = string | number | null | undefined; +export type CsvRow = CsvValue[]; + +/** Serializes rows as an Excel-compatible UTF-8 CSV document. */ +export const serializeCsv = (rows: CsvRow[]): string => + rows + .map(row => row.map(value => escapeCsvField(value?.toString() ?? '')).join(',')) + .join('\r\n'); + +/** Downloads CSV rows using a filename derived from the widget title. */ +export const downloadCsv = (rows: CsvRow[], title?: string): void => { + const url = URL.createObjectURL(new Blob([serializeCsv(rows)], { type: 'text/csv;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = getCsvFileName(title); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +/** Escapes a single CSV field when it contains delimiters, quotes, or line breaks. */ +const escapeCsvField = (value: string): string => + /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + +/** Returns a Windows-safe CSV filename while preserving valid custom title characters. */ +const getCsvFileName = (title?: string): string => { + const invalidCharacters = '<>:"/\\|?*'; + const safeTitle = Array.from(title ?? '').map(character => + character.charCodeAt(0) < 32 || invalidCharacters.includes(character) ? '_' : character + ).join('').trim().replace(/[. ]+$/g, ''); + const isReserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(safeTitle); + const fileName = safeTitle.length === 0 ? 'trend-data' : isReserved ? `_${safeTitle}` : safeTitle; + return `${fileName}.csv`; +}; diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts index 49e77ef7..d2cc6639 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetRegistry.ts @@ -45,7 +45,7 @@ export interface ITrendWidgetProps { /** Selected channels and their per-series display settings and data-availability state. */ ChannelInfo: TrendSearch.ISeriesSettings[], /** Updates channel series settings, including whether each series contains data. */ - SetChannelInfo: (newSettings: TrendSearch.ISeriesSettings[]) => void, + SetChannelInfo: React.Dispatch>, /** Controls which data series, such as minimum, average, and maximum, are plotted. */ PlotFilter: IMultiCheckboxOption[], /** Available widget height in pixels. */ diff --git a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetWrapper.tsx b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetWrapper.tsx index bd67dd7c..f52abb43 100644 --- a/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetWrapper.tsx +++ b/SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/TrendWidgetWrapper.tsx @@ -259,7 +259,8 @@ const TrendPlot = (props: IContainerProps) => { Type: props.LineDefaults.Average.Default.Type, Axis: 'left', Label: 'avg', - HasData: false + HasData: false, + Enabled: true } } }; @@ -276,14 +277,16 @@ const TrendPlot = (props: IContainerProps) => { Type: defaultsUsed.Default.Type, Axis: 'left', Label: label, - HasData: false + HasData: false, + Enabled: true }; if (props.Plot.Type === 'Histogram') (settings.Settings as TrendSearch.IHistogramSeriesSettings)[series.TypeName] = { ...lineSettings, ShowCumulativeProbability: true, CumulativeProbabilityColor: color?.[series.TypeName] ?? color.Average, - CumulativeProbabilityLabel: `${label} Cumulative Probability` + CumulativeProbabilityLabel: `${label} Cumulative Probability`, + CumulativeProbabilityEnabled: true }; else (settings.Settings as TrendSearch.ILineSeriesSettings)[series.TypeName] = lineSettings; @@ -316,11 +319,13 @@ const TrendPlot = (props: IContainerProps) => { (props.Plot.Type === 'Statistics' ? { ID: 0, ChannelID: channel.ChannelID, TypeName: key, TypeDescription: key } : undefined); const label = constructLabel(channel, series); lineSettings[key].Label = label; + lineSettings[key].Enabled ??= true; if (props.Plot.Type === 'Histogram') { const histogramSettings = lineSettings[key] as TrendSearch.IHistogramSettings; histogramSettings.ShowCumulativeProbability ??= true; histogramSettings.CumulativeProbabilityColor ??= histogramSettings.Color; histogramSettings.CumulativeProbabilityLabel ??= `${label} Cumulative Probability`; + histogramSettings.CumulativeProbabilityEnabled ??= true; } } ); diff --git a/SEBrowser/Scripts/TSX/global.d.ts b/SEBrowser/Scripts/TSX/global.d.ts index 65cb029c..9a890930 100644 --- a/SEBrowser/Scripts/TSX/global.d.ts +++ b/SEBrowser/Scripts/TSX/global.d.ts @@ -218,7 +218,8 @@ export namespace TrendSearch { Color: string, Axis: 'right' | 'left', Label: string, - HasData: boolean + HasData: boolean, + Enabled?: boolean } // Signifies Settings for a particular type of graph series @@ -230,7 +231,8 @@ export namespace TrendSearch { interface IHistogramSettings extends ILineSettings { ShowCumulativeProbability: boolean, CumulativeProbabilityColor: string, - CumulativeProbabilityLabel: string + CumulativeProbabilityLabel: string, + CumulativeProbabilityEnabled?: boolean } interface IHistogramSeriesSettings extends ILineSeriesSettings { diff --git a/SEBrowser/package-lock.json b/SEBrowser/package-lock.json index 7efe5521..8350d610 100644 --- a/SEBrowser/package-lock.json +++ b/SEBrowser/package-lock.json @@ -9,13 +9,13 @@ "version": "3.1.0", "dependencies": { "@gpa-gemstone/application-typings": "0.0.99", - "@gpa-gemstone/common-pages": "0.0.184", + "@gpa-gemstone/common-pages": "0.0.187", "@gpa-gemstone/gpa-symbols": "0.0.64", "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-forms": "1.1.125", - "@gpa-gemstone/react-graph": "1.0.112", - "@gpa-gemstone/react-interactive": "1.0.193", - "@gpa-gemstone/react-table": "1.2.117", + "@gpa-gemstone/react-forms": "1.1.127", + "@gpa-gemstone/react-graph": "1.0.115", + "@gpa-gemstone/react-interactive": "1.0.195", + "@gpa-gemstone/react-table": "1.2.119", "@reduxjs/toolkit": "1.8.3", "d3": "7.3.0", "esri-leaflet": "3.0.4", @@ -834,17 +834,17 @@ } }, "node_modules/@gpa-gemstone/common-pages": { - "version": "0.0.184", - "resolved": "https://registry.npmjs.org/@gpa-gemstone/common-pages/-/common-pages-0.0.184.tgz", - "integrity": "sha512-RXVBxjQBADZQfPrfGr43JdPHak8icscs/txKz2jdS8v27+zrGamHbnuK64AhOzK93KZtqV21NZDQsqsGT/8UOg==", + "version": "0.0.187", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/common-pages/-/common-pages-0.0.187.tgz", + "integrity": "sha512-jmnYvuFSbaJ+OQ2erVbNpS7RI0PcdTbOHHO3f2wPdWeN0LWckngbDTWZNLxsEZ2cOJBA/xHJ0pG4CDDGjRidFQ==", "license": "MIT", "dependencies": { "@gpa-gemstone/application-typings": "0.0.99", - "@gpa-gemstone/gpa-symbols": "0.0.64", + "@gpa-gemstone/gpa-symbols": "0.0.65", "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-forms": "1.1.125", - "@gpa-gemstone/react-interactive": "1.0.193", - "@gpa-gemstone/react-table": "1.2.117", + "@gpa-gemstone/react-forms": "1.1.127", + "@gpa-gemstone/react-interactive": "1.0.195", + "@gpa-gemstone/react-table": "1.2.119", "@reduxjs/toolkit": "1.8.3", "crypto-js": "^4.2.0", "moment": "^2.29.4", @@ -857,6 +857,152 @@ "react-dom": "18.2.0" } }, + "node_modules/@gpa-gemstone/common-pages/node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/@gpa-gemstone/gpa-symbols": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.65.tgz", + "integrity": "sha512-Crr8+/fK6ned0yKqGgde9cJwgbsoH4/4Rq5KUOOSrE56iIeozbBMeiA4aMPEy/IkhYllI7FyJxS8UVq7tqeflA==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.14.5", + "babel-jest": "^29.0.0", + "babel-loader": "^8.2.2", + "jest-cli": "^29.0.0", + "webpack": "^5.47.0", + "webpack-cli": "^4.7.2", + "yarn": "^1.22.11" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "license": "MIT", + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "license": "MIT", + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/common-pages/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@gpa-gemstone/gpa-symbols": { "version": "0.0.64", "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.64.tgz", @@ -1017,13 +1163,13 @@ } }, "node_modules/@gpa-gemstone/react-forms": { - "version": "1.1.125", - "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-forms/-/react-forms-1.1.125.tgz", - "integrity": "sha512-bnAI5x8v++LnO7Ba+alvMponnMu+d7FoeLElmnOfZ5Scl6//grDlU24KyX0RL3fFAFJKVUFL6vHk4kfin8Au8A==", + "version": "1.1.127", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-forms/-/react-forms-1.1.127.tgz", + "integrity": "sha512-Cb0j3ZBN0LVBZVgomiYWB1rrVFeFAuZVeOrxasNv3rGsB/uWAH4pUg+o8QWvjTvhZD83IHzVS7gudZC/W3GbMw==", "license": "MIT", "dependencies": { "@gpa-gemstone/application-typings": "0.0.99", - "@gpa-gemstone/gpa-symbols": "0.0.64", + "@gpa-gemstone/gpa-symbols": "0.0.65", "@gpa-gemstone/helper-functions": "0.0.61", "lodash": "^4.17.21", "moment": "2.29.4", @@ -1035,63 +1181,647 @@ "react": "18.2.0" } }, - "node_modules/@gpa-gemstone/react-graph": { - "version": "1.0.112", - "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-graph/-/react-graph-1.0.112.tgz", - "integrity": "sha512-WxlYOEE5uap6qRbk9jXi8DyAtRBIwtAMSX1ACDMjURcTGfIHRar9DHxjvKVOlyJuxlP/FUmSFLFOzw3pM+I9cA==", + "node_modules/@gpa-gemstone/react-forms/node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/@gpa-gemstone/gpa-symbols": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.65.tgz", + "integrity": "sha512-Crr8+/fK6ned0yKqGgde9cJwgbsoH4/4Rq5KUOOSrE56iIeozbBMeiA4aMPEy/IkhYllI7FyJxS8UVq7tqeflA==", "license": "MIT", "dependencies": { - "@gpa-gemstone/gpa-symbols": "0.0.64", - "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-forms": "1.1.125", - "html2canvas": "^1.4.1", - "lodash": "^4.17.21", - "moment": "2.29.4" + "@babel/preset-typescript": "^7.14.5", + "babel-jest": "^29.0.0", + "babel-loader": "^8.2.2", + "jest-cli": "^29.0.0", + "webpack": "^5.47.0", + "webpack-cli": "^4.7.2", + "yarn": "^1.22.11" }, "peerDependencies": { - "react": "18.2.0", - "react-dom": "18.2.0" + "react": "18.2.0" } }, - "node_modules/@gpa-gemstone/react-interactive": { - "version": "1.0.193", - "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-interactive/-/react-interactive-1.0.193.tgz", - "integrity": "sha512-qLS5BA1x6+Qp741zSImYu/pY3FLeKpI7VZ+c5Z9xHvKaCrj5m2KaSU+aixUHtvRA0kyfv5dYNF+XCkp5FoHGaA==", + "node_modules/@gpa-gemstone/react-forms/node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "license": "MIT", + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", "license": "MIT", "dependencies": { - "@gpa-gemstone/application-typings": "0.0.99", - "@gpa-gemstone/gpa-symbols": "0.0.64", - "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-forms": "1.1.125", - "@reduxjs/toolkit": "1.8.3", - "jquery": "^3.6.0", - "lodash": "^4.17.21", - "react-portal": "4.2.2", - "react-redux": "8.0.2", - "react-router-dom": "6.30.03", - "styled-components": "5.3.3" + "envinfo": "^7.7.3" }, "peerDependencies": { - "leaflet": "1.9.4", - "react": "18.2.0", - "react-dom": "18.2.0" + "webpack-cli": "4.x.x" } }, - "node_modules/@gpa-gemstone/react-table": { - "version": "1.2.117", - "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-table/-/react-table-1.2.117.tgz", - "integrity": "sha512-xsX0F1UG1tdXc1NB1LT+Xagw8IahVVmGvE5o+3W3DEj95ylFEwGfzM0rMG5bsfSO4dNuWFzbDZ64NkDpBgt+Uw==", + "node_modules/@gpa-gemstone/react-forms/node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", "license": "MIT", "dependencies": { - "@gpa-gemstone/gpa-symbols": "0.0.64", - "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-interactive": "1.0.193", - "lodash": "^4.17.21", - "react-portal": "4.2.2" + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "react": "18.2.0", - "react-dom": "18.2.0" + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-forms/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-graph": { + "version": "1.0.115", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-graph/-/react-graph-1.0.115.tgz", + "integrity": "sha512-7JZqqEyvk+IxKqbQ6cALV9EuD/Pe7jed1WscpdgMR37wJZXB01Z7jMktr7zYsB4WbR7HkonUSBwFBBgYMaa7xw==", + "license": "MIT", + "dependencies": { + "@gpa-gemstone/gpa-symbols": "0.0.65", + "@gpa-gemstone/helper-functions": "0.0.61", + "@gpa-gemstone/react-forms": "1.1.127", + "html2canvas": "^1.4.1", + "lodash": "^4.17.21", + "moment": "2.29.4" + }, + "peerDependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/@gpa-gemstone/gpa-symbols": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.65.tgz", + "integrity": "sha512-Crr8+/fK6ned0yKqGgde9cJwgbsoH4/4Rq5KUOOSrE56iIeozbBMeiA4aMPEy/IkhYllI7FyJxS8UVq7tqeflA==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.14.5", + "babel-jest": "^29.0.0", + "babel-loader": "^8.2.2", + "jest-cli": "^29.0.0", + "webpack": "^5.47.0", + "webpack-cli": "^4.7.2", + "yarn": "^1.22.11" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "license": "MIT", + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "license": "MIT", + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-graph/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-interactive": { + "version": "1.0.195", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-interactive/-/react-interactive-1.0.195.tgz", + "integrity": "sha512-QMW0EF6OjqMtXFsYZnTimQSAIWMjM5u5OXsuWOFgxQBTl0UkDezdxuDouTOONCqKcx8bwga5FCLP0LCnsjx1Aw==", + "license": "MIT", + "dependencies": { + "@gpa-gemstone/application-typings": "0.0.99", + "@gpa-gemstone/gpa-symbols": "0.0.65", + "@gpa-gemstone/helper-functions": "0.0.61", + "@gpa-gemstone/react-forms": "1.1.127", + "@reduxjs/toolkit": "1.8.3", + "jquery": "^3.6.0", + "lodash": "^4.17.21", + "react-portal": "4.2.2", + "react-redux": "8.0.2", + "react-router-dom": "6.30.03", + "styled-components": "5.3.3" + }, + "peerDependencies": { + "leaflet": "1.9.4", + "react": "18.2.0", + "react-dom": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/@gpa-gemstone/gpa-symbols": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.65.tgz", + "integrity": "sha512-Crr8+/fK6ned0yKqGgde9cJwgbsoH4/4Rq5KUOOSrE56iIeozbBMeiA4aMPEy/IkhYllI7FyJxS8UVq7tqeflA==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.14.5", + "babel-jest": "^29.0.0", + "babel-loader": "^8.2.2", + "jest-cli": "^29.0.0", + "webpack": "^5.47.0", + "webpack-cli": "^4.7.2", + "yarn": "^1.22.11" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "license": "MIT", + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "license": "MIT", + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-interactive/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-table": { + "version": "1.2.119", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/react-table/-/react-table-1.2.119.tgz", + "integrity": "sha512-S2ZpVDiggcYWp2ADZEGz/wWXfXpWkMJRfCAscwGC2cI8+QnSsxNnyzlp+heEvhQFP7L086/vpL1noe3CgyGPsw==", + "license": "MIT", + "dependencies": { + "@gpa-gemstone/gpa-symbols": "0.0.65", + "@gpa-gemstone/helper-functions": "0.0.61", + "@gpa-gemstone/react-interactive": "1.0.195", + "lodash": "^4.17.21", + "react-portal": "4.2.2" + }, + "peerDependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/@gpa-gemstone/gpa-symbols": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@gpa-gemstone/gpa-symbols/-/gpa-symbols-0.0.65.tgz", + "integrity": "sha512-Crr8+/fK6ned0yKqGgde9cJwgbsoH4/4Rq5KUOOSrE56iIeozbBMeiA4aMPEy/IkhYllI7FyJxS8UVq7tqeflA==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.14.5", + "babel-jest": "^29.0.0", + "babel-loader": "^8.2.2", + "jest-cli": "^29.0.0", + "webpack": "^5.47.0", + "webpack-cli": "^4.7.2", + "yarn": "^1.22.11" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "license": "MIT", + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "license": "MIT", + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@gpa-gemstone/react-table/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" } }, "node_modules/@humanwhocodes/config-array": { diff --git a/SEBrowser/package.json b/SEBrowser/package.json index e1b32291..c5590702 100644 --- a/SEBrowser/package.json +++ b/SEBrowser/package.json @@ -31,13 +31,13 @@ }, "dependencies": { "@gpa-gemstone/application-typings": "0.0.99", - "@gpa-gemstone/common-pages": "0.0.184", + "@gpa-gemstone/common-pages": "0.0.187", "@gpa-gemstone/gpa-symbols": "0.0.64", "@gpa-gemstone/helper-functions": "0.0.61", - "@gpa-gemstone/react-forms": "1.1.125", - "@gpa-gemstone/react-graph": "1.0.112", - "@gpa-gemstone/react-interactive": "1.0.193", - "@gpa-gemstone/react-table": "1.2.117", + "@gpa-gemstone/react-forms": "1.1.127", + "@gpa-gemstone/react-graph": "1.0.115", + "@gpa-gemstone/react-interactive": "1.0.195", + "@gpa-gemstone/react-table": "1.2.119", "@reduxjs/toolkit": "1.8.3", "d3": "7.3.0", "esri-leaflet": "3.0.4", diff --git a/docs/conventions/tsx-conventions.md b/docs/conventions/tsx-conventions.md new file mode 100644 index 00000000..12945405 --- /dev/null +++ b/docs/conventions/tsx-conventions.md @@ -0,0 +1,257 @@ +# React/TSX Component Conventions + +These conventions define the shape of a well-formed component file. Follow them +when writing new components; check against them when reviewing or refactoring. +The goal is that any developer can open any component and find things in the +same place every time: constants at top, hooks grouped by kind, JSX in the +middle, pure helpers at the bottom. + +When editing an existing file, match what's already there for anything not +covered here — never reformat unrelated code just to satisfy a rule. + +## File anatomy + +Every component file follows this top-to-bottom order: + +``` +1. License/file header comment (if project uses one — copy an existing file's) +2. Imports (React first, then third-party, then project imports) +3. Props interface(s) +5. Module-level constants and default objects +6. The component +7. Pure helper functions +8. export default ComponentName; +``` + +### One component per file + +Each exported component gets its own file, named after the component +(`AdapterStateBadge.tsx` exports `AdapterStateBadge`). Small private +sub-components used only by this component (a `HeaderContent`, a table row) +may live in the same file below the main component — they still get their own +props interface. + +### Constants at the top + +Anything that doesn't depend on props or state is module-level, defined above +the component: option lists, default records, page sizes, format strings. + +```ts +const PAGE_SIZE = 25; +const format = 'MM/DD/YYYY HH:mm:ss.SSS'; +const limitClauseOptions = [ + { Label: "TOP", Value: "TOP" }, + { Label: "ALL", Value: "ALL" }, +] +``` + +If it's inside the component but never uses props/state/hooks, move it out. + +### Pure functions at the bottom + +Any function that doesn't touch component state belongs outside the component +body, placed **before** the `export default` line at the bottom of the file. +Export them (named) only if another file reuses them. This keeps the component +body focused on state and rendering, and keeps helpers from being re-created +per render. + +```ts + +const getClassName = (status: ServiceStatus) => { + switch (status) { + case ServiceStatus.Normal: return 'badge-success'; + ... + } +} + +export default AdapterStateBadge; +``` + +## Props + +### Document every prop + +Add a JSDoc comment immediately above every property in a props interface. +Describe what the prop provides or controls; for callbacks, describe when the +component invokes it. + +```ts +interface IProps { + /** The adapter record displayed by the component. */ + Adapter: Common.IAdapter; + /** Called after the user saves a valid adapter change. */ + OnSave: (adapter: Common.IAdapter) => void; +} +``` + +### Every component takes an interface + +No inline prop type literals. Declare an interface — `IProps` for the file's +main component, `IProps` for secondary components in the same file +(`IHeaderContentProps`). Interface names are prefixed with `I`. Generic +components parameterize the interface: `IProps`. + +### Props are PascalCase + +`HeaderText`, `SetIsCardOpen`, `IsOpenByDefault` — not `headerText`. Callback +props are verbs in PascalCase (`SetRecord`, `HandleSave`, `OnClick`); boolean +props read as assertions (`IsCardEnabled`, `Disabled`, `AllowSort`). + +### Accessing props + +Either destructure in the signature or use `props.X` — but pick one per +component. Prefer destructuring when the component takes function(s) as props, with one +prop per line: + +```ts +const AdapterStateBadge = ({ + AdapterAcronym, + AdapterType, + RefreshIntervalMs +}: IProps) => { +``` + +## Component body ordering + +Inside the component, group by kind, in this order: + +``` +1. Context / store / ref hooks (usePageContext, useAppSelector, useRef) +2. State (all useState together) +3. Derived values (all useMemo together) +4. Effects (all useEffect together) +5. Callbacks / handlers (all useCallback / handler fns together) +6. Early returns (null guards, loading states) +7. return ( ...JSX... ) +``` + +Additional rules within this structure: + +- **Type state explicitly**: `React.useState(false)`, + `React.useState('uninitiated')`. +- **Comment each effect** with one or two lines immediately above it explaining + the purpose of the effect and, when useful, what causes it to run. Describe + the behavior specifically rather than restating that it is an effect. + + ```ts + // Load the adapter commands whenever the selected adapter changes. + React.useEffect(() => { + ... + }, [SelectedAdapter]); + ``` +- **Effects that fetch must clean up**: abort the request handle in the + cleanup function. + + ```ts + return () => { + if (handle?.abort != null) handle.abort(); + } + ``` + +- **Track async status** with the Application.Types.Status rather than ad-hoc booleans. +- **Local handlers** are camelCase `handleX` (`handleSetMax`, `handleToggle`); + memoized callbacks passed down as props may be PascalCase to match the prop + they fill (`GetAdapterCommands`). +- **Early returns** come after all hooks, before the JSX: + `if (SelectedRecord == null) return <>`. + +## JSX formatting + +### Multiline props + +When a component invocation takes more than two props, put each prop on its +own line with the closing `>` aligned: + +```tsx + + Record={SelectedRecord} + Field='ConnectionString' + Label='' + Rows={4} + Setter={EditSelectedRecord} + Disabled={props.Disabled} +/> +``` + +One or two short props can stay inline: ``. + +### Conditional rendering + +Use explicit ternaries against `null`, not `&&` short-circuits (which can leak +`0` or `''` into the output): + +```tsx +{props.IsCardEnabled ? + + ... + + : null} +``` + +## Null handling and misc semantics +- Prefer `??` for defaults: `const disabled = Disabled ?? false;`. +- Arrow-function components: `const Card = (props: IProps) => { ... }`. +- Namespace React import (`import * as React from 'react'`) with qualified + hooks (`React.useState`, `React.useEffect`) — don't destructure hooks from + the import. + +## JSDoc + +Use JSDoc comments for every function and component. + +- Add a short, simple JSDoc summary immediately above each component + declaration. +- Keep JSDoc for internal functions concise, usually a single-line summary. +- Give exported functions slightly more complete documentation because callers + cannot rely on the implementation for context. Describe the public behavior + and document parameters, return values, and important constraints when they + are not obvious from the types. + +```ts +/** Displays the current state of an adapter. */ +const AdapterStateBadge = (props: IProps) => { + ... +} + +/** Returns the CSS class for a service status. */ +const getClassName = (status: ServiceStatus) => { + ... +} + +/** + * Formats an adapter name for display throughout the application. + * + * @param adapter - The adapter whose name should be formatted. + * @returns The display name, or the adapter acronym when no name is available. + */ +export const formatAdapterName = (adapter: Common.IAdapter) => { + ... +} +``` + +## Applying these conventions + +**Writing a new component**: follow the file anatomy top to bottom; it doubles +as a checklist. + +**Reviewing/auditing existing components**: scan for violations in this +priority order — they're ordered by how much they hurt readability: + +1. Pure functions defined inside the component body (re-created every render, + clutters the state logic) → move below `export default`. +2. Missing or inline props typing → introduce `IProps`. +3. camelCase or inconsistently named props → PascalCase (note: renaming props + is a breaking change for callers — find and update all usages, or flag it + instead of renaming if the component is widely used). +4. Interleaved hooks (state/effect/state/callback mixed) → regroup by kind. +5. Constants defined inside the component → hoist to module level. +6. Multiple unrelated exported components in one file → split files (update + imports at all call sites). +7. Single-line JSX invocations with many props → multiline. +8. `&&` conditional rendering → ternary with `: null`. + +When auditing, report findings file-by-file before fixing, unless the user +asked you to just fix everything. Structural moves (1, 4, 5) are +behavior-preserving and safe; renames and file splits (3, 6) touch other files +— call those out explicitly. After any refactor, run the project's type-check +or build to verify nothing broke. \ No newline at end of file