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 7821015..19d7e17 100644 --- a/TSX/Widget/ESRIMap.tsx +++ b/TSX/Widget/ESRIMap.tsx @@ -24,7 +24,7 @@ import React from 'react'; import leaflet from 'leaflet'; import 'proj4leaflet'; -import { basemapLayer, dynamicMapLayer, Geometry, query } from 'esri-leaflet'; +import { basemapLayer, dynamicMapLayer, query } from 'esri-leaflet'; import moment from 'moment'; import { EventWidget } from '../global'; import { Application } from '@gpa-gemstone/application-typings'; @@ -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,9 +74,21 @@ interface ISettings { ClientID: string, PortalURL: string, TransmissionLineQuery: string, + StructureCrawlerURL: string, UserAuthentication: boolean } +const markerIcon = leaflet.divIcon({ + className: 'draggable-marker', + html: ` + + + + `, + iconSize: [24, 24], + iconAnchor: [12, 24], +}); + const ESRIMap: EventWidget.IWidget = { Name: 'ESRIMap', DefaultSettings: { @@ -113,6 +126,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 +175,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'} + /> +
+
@@ -246,16 +272,23 @@ const ESRIMap: EventWidget.IWidget = { Widget: (props: EventWidget.IWidgetProps) => { const map = React.useRef(null); const div = React.useRef(null); - const [status, setStatus] = React.useState('idle'); + const [lightningStatus, setLightningStatus] = React.useState('idle'); const [lightningInfo, setLightningInfo] = React.useState([]); const [faultInfo, setFaultInfo] = React.useState([]); const [window, setWindow] = React.useState(2); const [layerErrors, setLayerErrors] = React.useState([]); const [authToken, setAuthToken] = React.useState(""); + const [structureLocation, setStructureLocation] = React.useState(null); + const [meterLocation, setMeterLocation] = React.useState(null); + const [structureStatus, setStructureStatus] = React.useState('uninitiated'); + const markerLocation = structureLocation ?? meterLocation; + const locationWarning = structureStatus === 'error' ? `Unable to load the nearest structure location. The map will use the meter's location instead.` : ''; + 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(() => { - setStatus("loading"); + setLightningStatus("loading"); const handle = $.ajax({ type: "GET", url: `${props.HomePath}api/EventWidgets/ESRIMap/GetLightningInfo/${props.EventID}/${window}`, @@ -265,9 +298,9 @@ const ESRIMap: EventWidget.IWidget = { async: true }).done((d) => { setLightningInfo(d); - setStatus("idle") + setLightningStatus("idle") }).fail(() => { - setStatus("error") + setLightningStatus("error") }); return () => { @@ -308,6 +341,7 @@ const ESRIMap: EventWidget.IWidget = { /* Get Fault Info */ React.useEffect(() => { //needs status handling + setFaultInfo([]); const handle = $.ajax({ type: "GET", url: `${props.HomePath}api/EventWidgets/FaultInformation/${props.EventID}`, @@ -328,10 +362,67 @@ const ESRIMap: EventWidget.IWidget = { }, [props.EventID]) + /* Get the event meter's location as soon as the event changes. */ + React.useEffect(() => { + setMeterLocation(null); + const handle = $.ajax({ + type: 'GET', + url: `${props.HomePath}api/EventWidgets/ESRIMap/SubstationLocation/${props.EventID}`, + dataType: 'json', + cache: true + }) as JQuery.jqXHR; + + handle.done((data) => { + const location = data[0]; + if (location == null) + throw new Error(`No meter location was found for event ${props.EventID}.`); + + setMeterLocation(location); + }); + + return () => { + if (handle.abort != null) + handle.abort(); + }; + }, [props.EventID, props.HomePath]); + + /* 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.trim().length === 0) + return; + + setStructureStatus('loading'); + const handle = $.ajax({ + type: 'GET', + url: resolveVars(props.Settings.StructureCrawlerURL, faultInfo), + dataType: 'text', + cache: true, + xhrFields: { withCredentials: true } + }).done((response) => { + const location = parseStructureLocation(response); + setStructureLocation(location); + setStructureStatus('idle'); + }).fail((response) => { + 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); - }, []); /* Create map and map layers */ @@ -396,15 +487,17 @@ const ESRIMap: EventWidget.IWidget = { /* Adds fault marker */ React.useEffect(() => { - if (faultInfo.length === 0 || map.current == null) return; + if (markerLocation == null || map.current == null) return; - const fault_marker = leaflet.marker([faultInfo[0]?.Latitude, faultInfo[0]?.Longitude]).addTo(map.current); + const coordinates: [number, number] = [markerLocation.Latitude, markerLocation.Longitude]; + const fault_marker = leaflet.marker(coordinates, { icon: markerIcon }).addTo(map.current); + map.current.setView(coordinates, map.current.getZoom()); return () => { map.current?.removeLayer(fault_marker); } - }, [faultInfo]); + }, [markerLocation]); /* Adds lightning markers */ React.useEffect(() => { @@ -491,14 +584,13 @@ const ESRIMap: EventWidget.IWidget = {
- - {layerErrors.length > 0 ? + {mapWarning.length > 0 ?
- Unable to load the {layerErrors.length} map layers. + {mapWarning}
-
: - null + + : null }
@@ -507,13 +599,13 @@ const ESRIMap: EventWidget.IWidget = {
- {status === 'loading' ? + {lightningStatus === 'loading' ?
: null} - {status === 'error' ? + {lightningStatus === 'error' ? An error occurred while fetching lightning data. : null} - {status === 'idle' && lightningInfo.length === 0 ? + {lightningStatus === 'idle' && lightningInfo.length === 0 ? No lightning records found. : null} @@ -581,30 +673,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 <>