Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## React/TSX conventions

Before creating, editing, refactoring, or reviewing any `.tsx` file, read and follow `docs/conventions/tsx-conventions.md`.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <ChannelTab {...props} Editor={HistogramChannelEditor} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IChartData>(null);
const [chartData, setChartData] = React.useState<IChartData | null>(null);
const [graphStatus, setGraphStatus] = React.useState<Application.Types.Status>('uninitiated');
const [hover, setHover] = React.useState<boolean>(false);
const [barColor, setBarColor] = React.useState<{ Hue: number, Saturation: number }>(null);
const [metaData, setMetaData] = React.useState<TrendSearch.IMetaData[]>(null);
const [barColor, setBarColor] = React.useState<{ Hue: number, Saturation: number } | null>(null);
const [metaData, setMetaData] = React.useState<TrendSearch.IMetaData[] | null>(null);
// Height mangement
const [plotHeight, setPlotHeight] = React.useState<number>(props.Height);
const [extraLegendHeight, setExtraLegendHeight] = React.useState<number>(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);

Expand Down Expand Up @@ -197,20 +197,49 @@ const CyclicHistogram = React.memo((props: ITrendWidgetProps) => {
: null
}
</h4>
<Plot height={plotHeight} width={props.Width} legendHeight={plotHeight / 2 + extraLegendHeight} legendWidth={props.Width / 2} menuLocation={generalSettings.MoveOptionsLeft ? 'left' : 'right'}
defaultTdomain={timeLimits} onSelect={props.OnSelect} onCapture={captureCallback} onCaptureComplete={() => 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}>
<Plot
height={plotHeight}
width={props.Width}
legendHeight={plotHeight / 2 + extraLegendHeight}
legendWidth={props.Width / 2}
menuLocation={generalSettings.MoveOptionsLeft ? 'left' : 'right'}
defaultTdomain={timeLimits}
onSelect={props.OnSelect}
onCapture={captureCallback}
onCaptureComplete={() => 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 :
<HeatMapChart data={chartData.Series} sampleMs={chartData.TimeSpan} binSize={chartData.BinSize} hue={barColor.Hue} saturation={barColor.Saturation} fillStyle={'fill'} axis={'left'} legendUnit={'%'} />
<HeatMapChart
data={chartData.Series}
sampleMs={chartData.TimeSpan}
binSize={chartData.BinSize}
hue={barColor.Hue}
saturation={barColor.Saturation}
fillStyle={'fill'}
axis={'left'}
legendUnit={'%'}
/>
}
{props.Overlays}
{props.Controls}
</Plot>
<ToolTip Show={hover} Position={'bottom'} Target={props.ID}>
Selected Channel has no Data for the selected Time Window.
</ToolTip>
</div>);
</div>
);
});

export { CyclicHistogram };
Original file line number Diff line number Diff line change
Expand Up @@ -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<IProps>) => {
return (
<>
{props.Title !== undefined ?
Expand All @@ -54,11 +52,13 @@ const GraphError = React.memo((props: IProps) => {
onClick={() => element.props?.onClick?.()}>
{element}
</button>
</div>);
</div>
);
return null;
})}
</div>
</>);
});
</>
);
};

export default GraphError;
118 changes: 111 additions & 7 deletions SEBrowser/Scripts/TSX/Components/TrendData/TrendPlot/Histogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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],
Expand All @@ -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.
Expand Down Expand Up @@ -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<boolean>
) => {
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 <GraphError Height={height} Title={props.Title}>{props.Controls}</GraphError>;

Expand Down Expand Up @@ -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}
Expand All @@ -156,7 +205,12 @@ const Histogram = React.memo((props: ITrendWidgetProps) => {
yDomain={props.AxisZoom}
>
{data?.Series.map((series, seriesIndex) =>
<BarGroup key={series.ID} Legend={series.Label}>
<BarGroup
key={series.ID}
Legend={series.Label}
Enabled={series.Settings.Enabled ?? true}
SetEnabled={enabled => setHistogramEnabled(series.ChannelID, series.SeriesKey, 'Enabled', enabled)}
>
{series.Percentages.map((percentage, binIndex) =>
<Bar
key={`${series.ID}_${binIndex}`}
Expand All @@ -180,6 +234,8 @@ const Histogram = React.memo((props: ITrendWidgetProps) => {
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}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -253,17 +309,40 @@ 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)
};
});
});

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;
Expand All @@ -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 };
Loading