diff --git a/TSX/Widget/AssetVoltageDisturbances.tsx b/TSX/Widget/AssetVoltageDisturbances.tsx index ed7362b..a3c22ab 100644 --- a/TSX/Widget/AssetVoltageDisturbances.tsx +++ b/TSX/Widget/AssetVoltageDisturbances.tsx @@ -20,7 +20,7 @@ // Generated original version of source code. // //****************************************************************************************************** -import React from 'react'; +import * as React from 'react'; import moment from 'moment'; import { Table, Column } from '@gpa-gemstone/react-table'; import { EventWidget } from '../global'; @@ -37,8 +37,15 @@ interface IDisturbanceData { StartTime: string; SeverityCode: string; IsWorstDisturbance: boolean; + GroupNumber?: number; + GroupColor?: string; + IsFirstGroupRow?: boolean; + IsLastGroupRow?: boolean; } +const GROUP_COLORS = ['var(--blue)', 'var(--orange)', 'var(--green)', 'var(--purple)', 'var(--red)', 'var(--teal)', 'var(--pink)', 'var(--indigo)']; + +/** Displays voltage disturbances for the selected event. */ const AssetVoltageDisturbances: EventWidget.IWidget<{}> = { Name: 'VoltageDisturbances', DefaultSettings: {}, @@ -46,7 +53,9 @@ const AssetVoltageDisturbances: EventWidget.IWidget<{}> = { Widget: (props: EventWidget.IWidgetProps<{}>) => { const [data, setData] = React.useState([]); const [status, setStatus] = React.useState('uninitiated'); + const groupedData = React.useMemo(() => groupDisturbances(data), [data]); + // Load voltage disturbances whenever the selected event or application path changes. React.useEffect(() => { setStatus('loading'); const handle = getDisturbanceData(props.HomePath, props.EventID); @@ -67,7 +76,7 @@ const AssetVoltageDisturbances: EventWidget.IWidget<{}> = { An error occurred while fetching voltage disturbance data. - : null} + : null} {status === 'loading' ?
@@ -77,77 +86,121 @@ const AssetVoltageDisturbances: EventWidget.IWidget<{}> = { No voltage disturbance data. - : - - Data={data} - KeySelector={(item) => item.ID} - OnSort={() => {/*Do Nothing*/ }} - SortKey={''} - Ascending={true} - TableClass="table" - TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%', height: 50 }} - TbodyStyle={{ display: 'block', overflowY: 'auto', width: '100%', maxHeight: props.MaxHeight ?? 500 }} - RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - Selected={(r) => r.IsWorstDisturbance} - > - - Key={'EventType'} - AllowSort={false} - Field={'EventType'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Disturbance Type - - - Key={'Phase'} - AllowSort={false} - Field={'Phase'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Phase - - - Key={'PerUnitMagnitude'} - AllowSort={false} - Field={'PerUnitMagnitude'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - Content={row => (row.item.PerUnitMagnitude * 100).toFixed(1)} - > - Magnitude (%) - - - Key={'DurationSeconds'} - AllowSort={false} - Field={'DurationSeconds'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - Content={row => (row.item.DurationSeconds * 1000).toFixed(2)} + : + + Data={groupedData} + KeySelector={(item) => item.ID} + OnSort={() => {/*Do Nothing*/ }} + SortKey={''} + Ascending={true} + TableClass='table' + TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%', height: 50 }} + TbodyStyle={{ display: 'block', overflowY: 'auto', width: '100%', maxHeight: props.MaxHeight ?? 500 }} + RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} > - Duration (ms) - - - Key={'StartTime'} - AllowSort={false} - Field={'StartTime'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - Content={row => moment(row.item.StartTime).format('HH:mm:ss.SSS')} - > - Start Time - - - Key={'SeverityCode'} - AllowSort={false} - Field={'SeverityCode'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Severity - - + + Key='GroupStatus' + AllowSort={false} + HeaderStyle={{ width: 180 }} + RowStyle={{ width: 180 }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style, 'left'); + return <> + {item.GroupNumber != null ? + + {item.EventType} Group {item.GroupNumber} + + : null} + {item.IsWorstDisturbance ? + Worst + : null} + ; + }} + > + Group / Status + + + Key='EventType' + AllowSort={false} + Field='EventType' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style); + return item.EventType; + }} + > + Disturbance Type + + + Key='Phase' + AllowSort={false} + Field='Phase' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style); + return item.Phase; + }} + > + Phase + + + Key='PerUnitMagnitude' + AllowSort={false} + Field='PerUnitMagnitude' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style); + return (item.PerUnitMagnitude * 100).toFixed(1); + }} + > + Magnitude (%) + + + Key='DurationSeconds' + AllowSort={false} + Field='DurationSeconds' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style); + return (item.DurationSeconds * 1000).toFixed(2); + }} + > + Duration (ms) + + + Key='StartTime' + AllowSort={false} + Field='StartTime' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style); + return moment(item.StartTime).format('HH:mm:ss.SSS'); + }} + > + Start Time + + + Key='SeverityCode' + AllowSort={false} + Field='SeverityCode' + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + Content={({ item, style }) => { + applyGroupCellStyle(item, style, 'right'); + return item.SeverityCode; + }} + > + Severity + + }
@@ -155,6 +208,90 @@ const AssetVoltageDisturbances: EventWidget.IWidget<{}> = { } } +interface IDisturbanceBucket { + Rows: IDisturbanceData[]; + StartTime: number; + EndTime: number; +} + +/** Groups disturbances of the same event type into contiguous display blocks, including singleton groups. */ +export const groupDisturbances = (data: IDisturbanceData[]): IDisturbanceData[] => { + const rowsByType = new Map(); + + data.forEach(row => { + const rows = rowsByType.get(row.EventType) ?? []; + rows.push(row); + rowsByType.set(row.EventType, rows); + }); + + const blocks: IDisturbanceBucket[] = []; + rowsByType.forEach(rows => { + const sortedRows = [...rows].sort(compareDisturbances); + let currentBlock: IDisturbanceBucket | undefined; + + sortedRows.forEach(row => { + const startTime = getStartTime(row); + const endTime = startTime + row.DurationSeconds * 1000; + + if (currentBlock == null || startTime > currentBlock.EndTime) { + currentBlock = { Rows: [row], StartTime: startTime, EndTime: endTime }; + blocks.push(currentBlock); + } + else { + currentBlock.Rows.push(row); + currentBlock.EndTime = Math.max(currentBlock.EndTime, endTime); + } + }); + }); + + blocks.sort((left, right) => left.StartTime - right.StartTime); + + const groupNumbersByType = new Map(); + let groupColorIndex = 0; + return blocks.reduce((result, block) => { + const eventType = block.Rows[0].EventType; + const groupNumber = (groupNumbersByType.get(eventType) ?? 0) + 1; + groupNumbersByType.set(eventType, groupNumber); + + const groupColor = GROUP_COLORS[groupColorIndex % GROUP_COLORS.length]; + groupColorIndex++; + block.Rows.forEach((row, index) => result.push({ + ...row, + GroupNumber: groupNumber, + GroupColor: groupColor, + IsFirstGroupRow: index === 0, + IsLastGroupRow: index === block.Rows.length - 1 + })); + return result; + }, []); +} + +/** Sorts disturbances by start time while preserving input order for equal starts. */ +const compareDisturbances = (left: IDisturbanceData, right: IDisturbanceData): number => { + return getStartTime(left) - getStartTime(right); +} + +/** Converts a disturbance start time to milliseconds. */ +const getStartTime = (row: IDisturbanceData): number => moment.utc(row.StartTime).valueOf(); + +/** Applies grouped-row borders to the style object Gemstone passes from Column.Content to its td. */ +const applyGroupCellStyle = (row: IDisturbanceData, style?: React.CSSProperties, columnEdge?: 'left' | 'right'): void => { + if (row.GroupColor == null || style == null) return; + + if (row.IsFirstGroupRow) + style.borderTop = `2px solid ${row.GroupColor}`; + + if (row.IsLastGroupRow) + style.borderBottom = `2px solid ${row.GroupColor}`; + + if (columnEdge === 'left') + style.borderLeft = `2px solid ${row.GroupColor}`; + + if (columnEdge === 'right') + style.borderRight = `2px solid ${row.GroupColor}`; +} + +/** Requests voltage disturbances for an event. */ const getDisturbanceData = (homePath: string, eventID: number) => { return $.ajax({ type: "GET", @@ -166,4 +303,4 @@ const getDisturbanceData = (homePath: string, eventID: number) => { }); } -export default AssetVoltageDisturbances; \ No newline at end of file +export default AssetVoltageDisturbances;