From b09a5eca4cdde392f03d5132784ed0addcb0eb44 Mon Sep 17 00:00:00 2001 From: prestoncraw Date: Fri, 28 Aug 2026 09:31:07 -0400 Subject: [PATCH 1/3] Move StructureCrawler request to clientside widget --- TSX/Widget/ESRIMap.tsx | 138 +++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/TSX/Widget/ESRIMap.tsx b/TSX/Widget/ESRIMap.tsx index 7821015..cf5c4f6 100644 --- a/TSX/Widget/ESRIMap.tsx +++ b/TSX/Widget/ESRIMap.tsx @@ -56,12 +56,13 @@ interface ILayerSetting { } interface IFaultInfo { - StationName: string, - Inception: number, + Key: string, + Value: string | number | null +} + +interface IStructureLocation { Latitude: number, - Longitude: number, - Distance: number, - AssetName: string + Longitude: number } interface ISettings { @@ -73,6 +74,7 @@ interface ISettings { ClientID: string, PortalURL: string, TransmissionLineQuery: string, + StructureCrawlerURL: string, UserAuthentication: boolean } @@ -113,6 +115,7 @@ const ESRIMap: EventWidget.IWidget = { TransmissionLineLayer: `http://pq/arcgisproxynew/proxy.ashx?https://gis.tva.gov/arcgis/rest/services/EGIS_Transmission/Transmission_Grid_Restricted_2/MapServer/6`, TransmissionLineQuery: `UPPER(LINENAME) like '%{line}%'`, + StructureCrawlerURL: `http://opsptpsnet.cha.tva.gov:8025/TLI/StructureCrawler/FaultFinder.asp?Station={StationID}&Line={LineAssetKey}&Mileage={FaultDistance}`, UserAuthentication: false }, @@ -161,6 +164,18 @@ const ESRIMap: EventWidget.IWidget = { /> +
+
+ + Record={props.Settings} + Field={'StructureCrawlerURL'} + Help={'The full structure crawler URL, including query parameters. Populate a parameter from fault information using its field name in braces, for example Station={StationID} or Mileage={FaultDistance}.'} + Setter={(record) => props.SetSettings(record)} + Valid={() => true} + Label={'Structure Crawler URL'} + /> +
+
@@ -252,6 +267,8 @@ const ESRIMap: EventWidget.IWidget = { const [window, setWindow] = React.useState(2); const [layerErrors, setLayerErrors] = React.useState([]); const [authToken, setAuthToken] = React.useState(""); + const [structureLocation, setStructureLocation] = React.useState(null); + const [structureStatus, setStructureStatus] = React.useState('uninitiated'); /* Get Lightning Info */ React.useEffect(() => { @@ -328,6 +345,41 @@ const ESRIMap: EventWidget.IWidget = { }, [props.EventID]) + /* Get the nearest structure location from the configured structure crawler. */ + React.useEffect(() => { + setStructureLocation(null); + setStructureStatus('uninitiated'); + + const station = getFaultInfoValue(faultInfo, 'StationID'); + const line = getFaultInfoValue(faultInfo, 'LineAssetKey'); + const distance = getFaultInfoValue(faultInfo, 'FaultDistance'); + + if (station.length === 0 || line.length === 0 || distance.length === 0 || !props.Settings.StructureCrawlerURL) + return; + + setStructureStatus('loading'); + const handle = $.ajax({ + type: 'GET', + url: resolveVars(props.Settings.StructureCrawlerURL, faultInfo), + dataType: 'text', + cache: true, + xhrFields: { withCredentials: true } + }).done((response) => { + setStructureLocation(parseStructureLocation(response)); + setStructureStatus('idle'); + }).fail((response, status) => { + if (status === 'abort') return; + + setStructureStatus('error'); + console.error('Unable to fetch structure crawler data: ' + JSON.stringify(response)); + }); + + return () => { + if (handle?.abort != null) + handle.abort(); + }; + }, [faultInfo, props.Settings.StructureCrawlerURL]); + React.useEffect(() => { map.current = leaflet.map(div.current, { center: [props.Settings.CenterLat, props.Settings.CenterLong], zoom: props.Settings.Zoom }); basemapLayer('Gray').addTo(map.current); @@ -396,15 +448,15 @@ const ESRIMap: EventWidget.IWidget = { /* Adds fault marker */ React.useEffect(() => { - if (faultInfo.length === 0 || map.current == null) return; + if (structureLocation == null || map.current == null) return; - const fault_marker = leaflet.marker([faultInfo[0]?.Latitude, faultInfo[0]?.Longitude]).addTo(map.current); + const fault_marker = leaflet.marker([structureLocation.Latitude, structureLocation.Longitude]).addTo(map.current); return () => { map.current?.removeLayer(fault_marker); } - }, [faultInfo]); + }, [structureLocation]); /* Adds lightning markers */ React.useEffect(() => { @@ -500,6 +552,14 @@ const ESRIMap: EventWidget.IWidget = {
: null } + {structureStatus === 'error' ? +
+
+ Unable to load the nearest structure location. +
+
+ : null + }
@@ -581,30 +641,72 @@ const ESRIMap: EventWidget.IWidget = { } } +/** Replaces map-setting placeholders with values from fault information. */ function resolveVars(str: string, faultInfo: IFaultInfo[]): string { + let result = str; + for (const info of faultInfo) { + if (info.Value != null) + result = result.split(`{${info.Key}}`).join(info.Value.toString()); + } - const vars = { + const aliases = { 'time': '', 'station': '', 'line': '', + 'distance': '' }; if (faultInfo.length > 0) { - const t = moment(faultInfo[0]?.Inception); - vars["time"] = t.utc().format('YYYY-MM-DDTHH') + ':' + (t.minutes() - t.minutes() % 5).toString(); - vars["station"] = faultInfo[0]?.StationName.toUpperCase(); - vars["line"] = faultInfo[0]?.AssetName.toUpperCase(); - } + const t = moment(getFaultInfoValue(faultInfo, 'FaultTime')); + if (t.isValid()) + aliases["time"] = t.utc().format('YYYY-MM-DDTHH') + ':' + (t.minutes() - t.minutes() % 5).toString(); - let result = str; - for (const key in vars) { - if (str.includes(`\{${key}\}`)) - result = result.replace(`\{${key}\}`, vars[key]); + aliases["station"] = getFaultInfoValue(faultInfo, 'StationID').toUpperCase(); + aliases["line"] = getFaultInfoValue(faultInfo, 'LineAssetKey').toUpperCase(); + aliases["distance"] = getFaultInfoValue(faultInfo, 'FaultDistance'); } + + for (const key in aliases) + result = result.split(`{${key}}`).join(aliases[key]); + return result; } +/** Returns a fault-information value as text. */ +function getFaultInfoValue(faultInfo: IFaultInfo[], key: string): string { + const value = faultInfo.find(info => info.Key === key)?.Value; + return value == null ? '' : value.toString(); +} + +/** Parses the first valid latitude and longitude from a structure crawler HTML/CSV response. */ +function parseStructureLocation(response: string): IStructureLocation | null { + const document = new DOMParser().parseFromString(response, 'text/html'); + const csv = document.body.textContent?.trim() ?? ''; + const lines = csv.split(/[\r\n]+/).map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length < 2) + return null; + + const fields = lines[0].split(',').map(field => field.trim()); + const latitudeIndex = fields.findIndex(field => field.toLowerCase() === 'latitude'); + const longitudeIndex = fields.findIndex(field => field.toLowerCase() === 'longitude'); + + if (latitudeIndex === -1 || longitudeIndex === -1) + return null; + + for (const line of lines.slice(1)) { + const values = line.split(',').map(value => value.trim()); + const latitude = parseFloat(values[latitudeIndex]); + const longitude = parseFloat(values[longitudeIndex]); + + if (Number.isFinite(latitude) && Number.isFinite(longitude)) + return { Latitude: latitude, Longitude: longitude }; + } + + return null; +} + const LayerSettings = (props: { Layer: ILayerSetting, SetLayer: (layer: ILayerSetting | undefined) => void, Index: number }) => { return <>
From 57a976186f28849a771264dbac30e30988b2d8d6 Mon Sep 17 00:00:00 2001 From: prestoncraw Date: Fri, 28 Aug 2026 11:53:27 -0400 Subject: [PATCH 2/3] Add proxy route and add fallback logic --- API/Visualizations/ESRIMapController.cs | 4 ++ TSX/Widget/ESRIMap.tsx | 79 +++++++++++++++++++------ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/API/Visualizations/ESRIMapController.cs b/API/Visualizations/ESRIMapController.cs index cc38c45..33425f4 100644 --- a/API/Visualizations/ESRIMapController.cs +++ b/API/Visualizations/ESRIMapController.cs @@ -58,6 +58,10 @@ public ESRIMapController(IAPICredentialRetriever retriever) : base(retriever) { public async ServerResponse GetLightningInfo(int eventID, int timeWindow, CancellationToken token) => await ForwardRequest(token).ConfigureAwait(false); + [Route("SubstationLocation/{eventID:int}"), HttpGet] + public async ServerResponse GetSubstationLocation(int eventID, CancellationToken token) => + await ForwardRequest(token).ConfigureAwait(false); + [Route("NearestStructure/{station}/{line}"), HttpGet] public async ServerResponse GetNearestStructure(string station, string line, CancellationToken token) => await ForwardRequest(token).ConfigureAwait(false); diff --git a/TSX/Widget/ESRIMap.tsx b/TSX/Widget/ESRIMap.tsx index cf5c4f6..78650c6 100644 --- a/TSX/Widget/ESRIMap.tsx +++ b/TSX/Widget/ESRIMap.tsx @@ -269,6 +269,11 @@ const ESRIMap: EventWidget.IWidget = { const [authToken, setAuthToken] = React.useState(""); const [structureLocation, setStructureLocation] = React.useState(null); const [structureStatus, setStructureStatus] = React.useState('uninitiated'); + const [stationStatus, setStationStatus] = React.useState('uninitiated'); + const [crawlerReturnedNoCoordinates, setCrawlerReturnedNoCoordinates] = React.useState(false); + const locationWarning = getLocationWarning(structureStatus, stationStatus, crawlerReturnedNoCoordinates, structureLocation != null); + const mapWarning = [layerErrors.length > 0 ? `Unable to load ${layerErrors.length} map ${layerErrors.length === 1 ? 'layer' : 'layers'}.` : '', locationWarning] + .filter(message => message.length > 0).join(' '); /* Get Lightning Info */ React.useEffect(() => { @@ -345,10 +350,33 @@ const ESRIMap: EventWidget.IWidget = { }, [props.EventID]) - /* Get the nearest structure location from the configured structure crawler. */ + /* Get the nearest structure location, falling back to the meter's substation location. */ React.useEffect(() => { setStructureLocation(null); setStructureStatus('uninitiated'); + setStationStatus('uninitiated'); + setCrawlerReturnedNoCoordinates(false); + + let stationHandle: JQuery.jqXHR | undefined; + + /** Loads the event meter's substation location as a fallback. */ + const loadStationLocation = () => { + setStationStatus('loading'); + stationHandle = $.ajax({ + type: 'GET', + url: `${props.HomePath}api/EventWidgets/ESRIMap/SubstationLocation/${props.EventID}`, + dataType: 'json', + cache: true + }) as JQuery.jqXHR; + + stationHandle.done((data) => { + setStructureLocation(data[0] ?? null); + setStationStatus('idle'); + }).fail((response) => { + setStationStatus('error'); + console.error('Unable to fetch the substation location: ' + JSON.stringify(response)); + }); + }; const station = getFaultInfoValue(faultInfo, 'StationID'); const line = getFaultInfoValue(faultInfo, 'LineAssetKey'); @@ -365,20 +393,30 @@ const ESRIMap: EventWidget.IWidget = { cache: true, xhrFields: { withCredentials: true } }).done((response) => { - setStructureLocation(parseStructureLocation(response)); - setStructureStatus('idle'); - }).fail((response, status) => { - if (status === 'abort') return; + const location = parseStructureLocation(response); + setCrawlerReturnedNoCoordinates(location == null); + if (location == null) { + setStructureStatus('idle'); + loadStationLocation(); + return; + } + + setStructureLocation(location); + setStructureStatus('idle'); + }).fail((response) => { setStructureStatus('error'); console.error('Unable to fetch structure crawler data: ' + JSON.stringify(response)); + loadStationLocation(); }); return () => { if (handle?.abort != null) handle.abort(); + if (stationHandle?.abort != null) + stationHandle.abort(); }; - }, [faultInfo, props.Settings.StructureCrawlerURL]); + }, [faultInfo, props.EventID, props.HomePath, props.Settings.StructureCrawlerURL]); React.useEffect(() => { map.current = leaflet.map(div.current, { center: [props.Settings.CenterLat, props.Settings.CenterLong], zoom: props.Settings.Zoom }); @@ -450,7 +488,9 @@ const ESRIMap: EventWidget.IWidget = { React.useEffect(() => { if (structureLocation == null || map.current == null) return; - const fault_marker = leaflet.marker([structureLocation.Latitude, structureLocation.Longitude]).addTo(map.current); + const coordinates: [number, number] = [structureLocation.Latitude, structureLocation.Longitude]; + const fault_marker = leaflet.marker(coordinates).addTo(map.current); + map.current.setView(coordinates, map.current.getZoom()); return () => { map.current?.removeLayer(fault_marker); @@ -544,18 +584,10 @@ const ESRIMap: EventWidget.IWidget = {
- {layerErrors.length > 0 ? + {mapWarning.length > 0 ?
- Unable to load the {layerErrors.length} map layers. -
-
: - null - } - {structureStatus === 'error' ? -
-
- Unable to load the nearest structure location. + {mapWarning}
: null @@ -707,6 +739,19 @@ function parseStructureLocation(response: string): IStructureLocation | null { return null; } +/** Returns the warning for the structure-to-substation fallback. */ +function getLocationWarning(structureStatus: Application.Types.Status, stationStatus: Application.Types.Status, crawlerReturnedNoCoordinates: boolean, hasLocation: boolean): string { + if (stationStatus === 'uninitiated') return ''; + + const crawlerResult = structureStatus === 'error' ? 'failed' : crawlerReturnedNoCoordinates ? 'returned no coordinates' : ''; + if (crawlerResult.length === 0) return ''; + if (stationStatus === 'loading') + return `The structure crawler ${crawlerResult}. The map is attempting to use the meter's substation location instead.`; + if (hasLocation) + return `The structure crawler ${crawlerResult}. The map is using the meter's substation location instead.`; + return `The structure crawler ${crawlerResult}, and no valid substation location was available.`; +} + const LayerSettings = (props: { Layer: ILayerSetting, SetLayer: (layer: ILayerSetting | undefined) => void, Index: number }) => { return <>
From ab974e7d954803bbfb7c7f39fe098fa48095f350 Mon Sep 17 00:00:00 2001 From: prestoncraw Date: Fri, 28 Aug 2026 12:01:48 -0400 Subject: [PATCH 3/3] cleanup --- TSX/Widget/ESRIMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TSX/Widget/ESRIMap.tsx b/TSX/Widget/ESRIMap.tsx index 78650c6..87f04ad 100644 --- a/TSX/Widget/ESRIMap.tsx +++ b/TSX/Widget/ESRIMap.tsx @@ -382,7 +382,7 @@ const ESRIMap: EventWidget.IWidget = { const line = getFaultInfoValue(faultInfo, 'LineAssetKey'); const distance = getFaultInfoValue(faultInfo, 'FaultDistance'); - if (station.length === 0 || line.length === 0 || distance.length === 0 || !props.Settings.StructureCrawlerURL) + if (station.length === 0 || line.length === 0 || distance.length === 0 || props.Settings.StructureCrawlerURL.trim().length === 0) return; setStructureStatus('loading');