diff --git a/src/components/network-map-viewer/network/network-map.tsx b/src/components/network-map-viewer/network/network-map.tsx index fd1db690..de5f981d 100644 --- a/src/components/network-map-viewer/network/network-map.tsx +++ b/src/components/network-map-viewer/network/network-map.tsx @@ -5,19 +5,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import PropTypes from 'prop-types'; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { + forwardRef, + memo, + ReactNode, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { Box, decomposeColor } from '@mui/system'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { Replay } from '@mui/icons-material'; -import { Button, useTheme } from '@mui/material'; +import { Button, ButtonProps, useTheme } from '@mui/material'; import { FormattedMessage } from 'react-intl'; -import { Map, NavigationControl, useControl } from 'react-map-gl'; +import { Map, MapLib, MapRef, NavigationControl, useControl, ViewState, ViewStateChangeEvent } from 'react-map-gl'; import { getNominalVoltageColor } from '../../../utils/colors'; import { useNameOrId } from '../utils/equipmentInfosHandler'; import { GeoData } from './geo-data'; -import DrawControl, { getMapDrawer } from './draw-control'; -import { LineFlowColorMode, LineFlowMode, LineLayer } from './line-layer'; +import DrawControl, { DRAW_MODES, DrawControlProps, getMapDrawer } from './draw-control'; +import { LineFlowColorMode, LineFlowMode, LineLayer, LineLayerProps } from './line-layer'; import { MapEquipments } from './map-equipments'; import { SubstationLayer } from './substation-layer'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; @@ -27,7 +36,22 @@ import 'mapbox-gl/dist/mapbox-gl.css'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; -import { EQUIPMENT_TYPES } from '../utils/equipment-types'; +import { Feature, Polygon } from 'geojson'; +import { + EquimentLine, + Equipment, + EQUIPMENT_TYPES, + HvdcLineEquimentLine, + isLine, + isSubstation, + isVoltageLevel, + Line, + LineEquimentLine, + Substation, + TieLineEquimentLine, + VoltageLevel, +} from '../utils/equipment-types'; +import { PickingInfo } from 'deck.gl'; // MouseEvent.button https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button const MOUSE_EVENT_BUTTON_LEFT = 0; @@ -38,11 +62,13 @@ const MOUSE_EVENT_BUTTON_RIGHT = 2; * when a draw event is triggered, the event type is passed to the onDrawEvent callback * On create, when the user create a new polygon (shape finished) */ -export const DRAW_EVENT = { - CREATE: 1, - UPDATE: 2, - DELETE: 0, -}; +export enum DRAW_EVENT { + CREATE = 1, + UPDATE = 2, + DELETE = 0, +} + +export type MenuClickFunction = (equipment: T, eventX: number, eventY: number) => void; // Small boilerplate recommended by deckgl, to bridge to a react-map-gl control declaratively // see https://deck.gl/docs/api-reference/mapbox/mapbox-overlay#using-with-react-map-gl @@ -53,14 +79,24 @@ const DeckGLOverlay = forwardRef((props, ref) => { return null; }); +type TooltipType = { + equipmentId: string; + equipmentType: string; + pointerX: number; + pointerY: number; + visible: boolean; +}; + const PICKING_RADIUS = 5; const CARTO = 'carto'; const CARTO_NOLABEL = 'cartonolabel'; const MAPBOX = 'mapbox'; +type MapLibrary = typeof CARTO | typeof CARTO_NOLABEL | typeof MAPBOX; const LIGHT = 'light'; const DARK = 'dark'; +type MapTheme = typeof LIGHT | typeof DARK; const styles = { mapManualRefreshBackdrop: { @@ -83,643 +119,688 @@ const FALLBACK_MAPBOX_TOKEN = const SUBSTATION_LAYER_PREFIX = 'substationLayer'; const LINE_LAYER_PREFIX = 'lineLayer'; const LABEL_SIZE = 12; + +type Centered = { + lastCenteredSubstation: string | null; + centeredSubstationId?: string | null; + centered: boolean; +}; + const INITIAL_CENTERED = { lastCenteredSubstation: null, centeredSubstationId: null, centered: false, -}; +} satisfies Centered; + const DEFAULT_LOCATE_SUBSTATION_ZOOM_LEVEL = 12; // get polygon coordinates (features) or an empty object -function getPolygonFeatures() { +function getPolygonFeatures(): Feature | Record { return getMapDrawer()?.getAll()?.features[0] ?? {}; } -const NetworkMap = forwardRef((props, ref) => { - const [labelsVisible, setLabelsVisible] = useState(false); - const [showLineFlow, setShowLineFlow] = useState(true); - const [showTooltip, setShowTooltip] = useState(true); - const mapRef = useRef(); - const deckRef = useRef(); - const [centered, setCentered] = useState(INITIAL_CENTERED); - const lastViewStateRef = useRef(null); - const [tooltip, setTooltip] = useState({}); - const theme = useTheme(); - const foregroundNeutralColor = useMemo(() => { - const labelColor = decomposeColor(theme.palette.text.primary).values; - labelColor[3] *= 255; - return labelColor; - }, [theme]); - const [cursorType, setCursorType] = useState('grab'); - const [isDragging, setDragging] = useState(false); - - //NOTE these constants are moved to the component's parameters list - //const currentNode = useSelector((state) => state.currentTreeNode); - const { onPolygonChanged, centerOnSubstation, onDrawEvent, shouldDisableToolTip } = props; - - const { getNameOrId } = useNameOrId(props.useName); - - const readyToDisplay = props.mapEquipments !== null && props.geoData !== null && !props.disabled; - - const readyToDisplaySubstations = - readyToDisplay && props.mapEquipments.substations && props.geoData.substationPositionsById.size > 0; - - const readyToDisplayLines = - readyToDisplay && - (props.mapEquipments?.lines || props.mapEquipments?.hvdcLines || props.mapEquipments?.tieLines) && - props.mapEquipments.voltageLevels && - props.geoData.substationPositionsById.size > 0; - - const mapEquipmentsLines = useMemo(() => { - return [ - ...(props.mapEquipments?.lines.map((line) => ({ - ...line, - equipmentType: EQUIPMENT_TYPES.LINE, - })) ?? []), - ...(props.mapEquipments?.tieLines.map((tieLine) => ({ - ...tieLine, - equipmentType: EQUIPMENT_TYPES.TIE_LINE, - })) ?? []), - ...(props.mapEquipments?.hvdcLines.map((hvdcLine) => ({ - ...hvdcLine, - equipmentType: EQUIPMENT_TYPES.HVDC_LINE, - })) ?? []), - ]; - }, [props.mapEquipments?.hvdcLines, props.mapEquipments?.tieLines, props.mapEquipments?.lines]); - - const divRef = useRef(); - - const mToken = !props.mapBoxToken ? FALLBACK_MAPBOX_TOKEN : props.mapBoxToken; - - useEffect(() => { - if (centerOnSubstation === null) { - return; - } - setCentered({ - lastCenteredSubstation: null, - centeredSubstationId: centerOnSubstation?.to, - centered: true, - }); - }, [centerOnSubstation]); - - // TODO simplify this, now we use Map as the camera controlling component - // so we don't need the deckgl ref anymore. The following comments are - // probably outdated, cleanup everything: - // Do this in onAfterRender because when doing it in useEffect (triggered by calling setDeck()), - // it doesn't work in the case of using the browser backward/forward buttons (because in this particular case, - // we get the ref to the deck and it has not yet initialized..) - function onAfterRender() { - // TODO outdated comment - //use centered and deck to execute this block only once when the data is ready and deckgl is initialized - //TODO, replace the next lines with setProps( { initialViewState } ) when we upgrade to 8.1.0 - //see https://github.com/uber/deck.gl/pull/4038 - //This is a hack because it accesses the properties of deck directly but for now it works - if ( - (!centered.centered || - (centered.centeredSubstationId && centered.centeredSubstationId !== centered.lastCenteredSubstation)) && - props.geoData !== null - ) { - if (props.geoData.substationPositionsById.size > 0) { - if (centered.centeredSubstationId) { - const geodata = props.geoData.substationPositionsById.get(centered.centeredSubstationId); - if (!geodata) { - return; - } // can't center on substation if no coordinate. - mapRef.current?.flyTo({ - center: [geodata.lon, geodata.lat], - duration: 2000, - // only zoom if the current zoom is smaller than the new one - zoom: Math.max(mapRef.current?.getZoom(), props.locateSubStationZoomLevel), - essential: true, - }); - setCentered({ - lastCenteredSubstation: centered.centeredSubstationId, - centeredSubstationId: centered.centeredSubstationId, - centered: true, - }); - } else { - const coords = Array.from(props.geoData.substationPositionsById.entries()).map((x) => x[1]); - const maxlon = Math.max.apply( - null, - coords.map((x) => x.lon) - ); - const minlon = Math.min.apply( - null, - coords.map((x) => x.lon) - ); - const maxlat = Math.max.apply( - null, - coords.map((x) => x.lat) - ); - const minlat = Math.min.apply( - null, - coords.map((x) => x.lat) - ); - const marginlon = (maxlon - minlon) / 10; - const marginlat = (maxlat - minlat) / 10; - mapRef.current?.fitBounds( - [ - [minlon - marginlon / 2, minlat - marginlat / 2], - [maxlon + marginlon / 2, maxlat + marginlat / 2], - ], - { animate: false } - ); - setCentered({ - lastCenteredSubstation: null, - centered: true, - }); +type NetworkMapProps = { + disabled?: boolean; + geoData?: GeoData | null; + mapBoxToken?: string | null; + mapEquipments?: MapEquipments | null; + mapLibrary?: 'carto' | 'cartonolabel' | 'mapbox'; + mapTheme?: 'light' | 'dark'; + areFlowsValid?: boolean; + arrowsZoomThreshold?: number; + centerOnSubstation?: { to: string } | null; + displayOverlayLoader?: boolean; + filteredNominalVoltages?: number[] | null; + initialPosition?: [number, number]; + initialZoom?: number; + isManualRefreshBackdropDisplayed?: boolean; + labelsZoomThreshold?: number; + lineFlowAlertThreshold?: number; + lineFlowColorMode?: LineFlowColorMode; + lineFlowHidden?: boolean; + lineFlowMode?: LineFlowMode; + lineFullPath?: boolean; + lineParallelPath?: boolean; + renderPopover?: (equipmentId: string, divRef: HTMLDivElement | null) => ReactNode; + tooltipZoomThreshold?: number; + // With mapboxgl v2 (not a problem with maplibre), we need to call + // map.resize() when the parent size has changed, otherwise the map is not + // redrawn. It seems like this is autodetected when the browser window is + // resized, but not for programmatic resizes of the parent. For now in our + // app, only study display mode resizes programmatically + // use this prop to make the map resize when needed, each time this prop changes, map.resize() is trigged + triggerMapResizeOnChange?: unknown; + updatedLines?: LineLayerProps['updatedLines']; + useName?: boolean; + visible?: boolean; + shouldDisableToolTip?: boolean; + locateSubStationZoomLevel?: number; + onHvdcLineMenuClick?: MenuClickFunction; + onLineMenuClick?: MenuClickFunction; + onTieLineMenuClick?: MenuClickFunction; + onManualRefreshClick?: ButtonProps['onClick']; + onSubstationClick?: (idVoltageLevel: string) => void; + onSubstationClickChooseVoltageLevel?: (idSubstation: string, eventX: number, eventY: number) => void; + onSubstationMenuClick?: MenuClickFunction; + onVoltageLevelMenuClick?: MenuClickFunction; + onDrawPolygonModeActive?: DrawControlProps['onDrawPolygonModeActive']; + onPolygonChanged?: (polygoneFeature: Feature | Record) => void; + onDrawEvent?: (drawEvent: DRAW_EVENT) => void; +}; + +export type NetworkMapRef = { + getSelectedSubstations: () => Substation[]; + getSelectedLines: () => Line[]; + cleanDraw: () => void; + getMapDrawer: () => MapboxDraw | undefined; +}; + +const NetworkMap = forwardRef( + ( + { + areFlowsValid = true, + arrowsZoomThreshold = 7, + centerOnSubstation = null, + disabled = false, + displayOverlayLoader = false, + filteredNominalVoltages = null, + geoData = null, + initialPosition = [0, 0], + initialZoom = 5, + isManualRefreshBackdropDisplayed = false, + labelsZoomThreshold = 9, + lineFlowAlertThreshold = 100, + lineFlowColorMode = LineFlowColorMode.NOMINAL_VOLTAGE, + // lineFlowHidden = true, + lineFlowMode = LineFlowMode.FEEDERS, + lineFullPath = true, + lineParallelPath = true, + mapBoxToken = null, + mapEquipments = null, + mapLibrary = CARTO, + tooltipZoomThreshold = 7, + mapTheme = DARK, + triggerMapResizeOnChange = false, + updatedLines = [], + useName = true, + visible = true, + shouldDisableToolTip = false, + locateSubStationZoomLevel = DEFAULT_LOCATE_SUBSTATION_ZOOM_LEVEL, + onSubstationClick = () => {}, + onSubstationClickChooseVoltageLevel = () => {}, + onSubstationMenuClick = () => {}, + onVoltageLevelMenuClick = () => {}, + onLineMenuClick = () => {}, + onTieLineMenuClick = () => {}, + onHvdcLineMenuClick = () => {}, + onManualRefreshClick = () => {}, + renderPopover = (eId) => { + return eId; + }, + onDrawPolygonModeActive = (active: DRAW_MODES) => { + console.log('polygon drawing mode active: ', active ? 'active' : 'inactive'); + }, + onPolygonChanged = () => {}, + onDrawEvent = () => {}, + }, + ref + ) => { + const [labelsVisible, setLabelsVisible] = useState(false); + const [showLineFlow, setShowLineFlow] = useState(true); + const [showTooltip, setShowTooltip] = useState(true); + const mapRef = useRef(null); + const deckRef = useRef(); + const [centered, setCentered] = useState(INITIAL_CENTERED); + const lastViewStateRef = useRef(); + const [tooltip, setTooltip] = useState | null>({}); + const theme = useTheme(); + const foregroundNeutralColor = useMemo(() => { + const labelColor = decomposeColor(theme.palette.text.primary).values as [number, number, number, number]; + labelColor[3] *= 255; + return labelColor; + }, [theme]); + const [cursorType, setCursorType] = useState('grab'); + const [isDragging, setDragging] = useState(false); + + const { getNameOrId } = useNameOrId(useName); + + const readyToDisplay = mapEquipments !== null && geoData !== null && !disabled; + + const readyToDisplaySubstations = + readyToDisplay && mapEquipments.substations && geoData.substationPositionsById.size > 0; + + const readyToDisplayLines = + readyToDisplay && + (mapEquipments?.lines || mapEquipments?.hvdcLines || mapEquipments?.tieLines) && + mapEquipments.voltageLevels && + geoData.substationPositionsById.size > 0; + + const mapEquipmentsLines = useMemo(() => { + return [ + ...(mapEquipments?.lines.map( + (line) => + ({ + ...line, + equipmentType: EQUIPMENT_TYPES.LINE, + } as LineEquimentLine) + ) ?? []), + ...(mapEquipments?.tieLines.map( + (tieLine) => + ({ + ...tieLine, + equipmentType: EQUIPMENT_TYPES.TIE_LINE, + } as TieLineEquimentLine) + ) ?? []), + ...(mapEquipments?.hvdcLines.map( + (hvdcLine) => + ({ + ...hvdcLine, + equipmentType: EQUIPMENT_TYPES.HVDC_LINE, + } as HvdcLineEquimentLine) + ) ?? []), + ]; + }, [mapEquipments?.hvdcLines, mapEquipments?.tieLines, mapEquipments?.lines]) as EquimentLine[]; + + const divRef = useRef(null); + + const mToken = !mapBoxToken ? FALLBACK_MAPBOX_TOKEN : mapBoxToken; + + useEffect(() => { + if (centerOnSubstation === null) { + return; + } + setCentered({ + lastCenteredSubstation: null, + centeredSubstationId: centerOnSubstation?.to, + centered: true, + }); + }, [centerOnSubstation]); + + // TODO simplify this, now we use Map as the camera controlling component + // so we don't need the deckgl ref anymore. The following comments are + // probably outdated, cleanup everything: + // Do this in onAfterRender because when doing it in useEffect (triggered by calling setDeck()), + // it doesn't work in the case of using the browser backward/forward buttons (because in this particular case, + // we get the ref to the deck and it has not yet initialized..) + function onAfterRender() { + // TODO outdated comment + //use centered and deck to execute this block only once when the data is ready and deckgl is initialized + //TODO, replace the next lines with setProps( { initialViewState } ) when we upgrade to 8.1.0 + //see https://github.com/uber/deck.gl/pull/4038 + //This is a hack because it accesses the properties of deck directly but for now it works + if ( + (!centered.centered || + (centered.centeredSubstationId && + centered.centeredSubstationId !== centered.lastCenteredSubstation)) && + geoData !== null + ) { + if (geoData.substationPositionsById.size > 0) { + if (centered.centeredSubstationId) { + const geodata = geoData.substationPositionsById.get(centered.centeredSubstationId); + if (!geodata) { + return; + } // can't center on substation if no coordinate. + mapRef.current?.flyTo({ + center: [geodata.lon, geodata.lat], + duration: 2000, + // only zoom if the current zoom is smaller than the new one + zoom: Math.max(mapRef.current?.getZoom(), locateSubStationZoomLevel), + essential: true, + }); + setCentered({ + lastCenteredSubstation: centered.centeredSubstationId, + centeredSubstationId: centered.centeredSubstationId, + centered: true, + }); + } else { + const coords = Array.from(geoData.substationPositionsById.entries()).map((x) => x[1]); + const maxlon = Math.max.apply( + null, + coords.map((x) => x.lon) + ); + const minlon = Math.min.apply( + null, + coords.map((x) => x.lon) + ); + const maxlat = Math.max.apply( + null, + coords.map((x) => x.lat) + ); + const minlat = Math.min.apply( + null, + coords.map((x) => x.lat) + ); + const marginlon = (maxlon - minlon) / 10; + const marginlat = (maxlat - minlat) / 10; + mapRef.current?.fitBounds( + [ + [minlon - marginlon / 2, minlat - marginlat / 2], + [maxlon + marginlon / 2, maxlat + marginlat / 2], + ], + { animate: false } + ); + setCentered({ + lastCenteredSubstation: null, + centered: true, + }); + } } } } - } - function onViewStateChange(info) { - lastViewStateRef.current = info.viewState; - if ( - !info.interactionState || // first event of before an animation (e.g. clicking the +/- buttons of the navigation controls, gives the target - (info.interactionState && !info.interactionState.inTransition) // Any event not part of a animation (mouse panning or zooming) - ) { - if (info.viewState.zoom >= props.labelsZoomThreshold && !labelsVisible) { - setLabelsVisible(true); - } else if (info.viewState.zoom < props.labelsZoomThreshold && labelsVisible) { - setLabelsVisible(false); + function onViewStateChange(info: ViewStateChangeEvent) { + lastViewStateRef.current = info.viewState; + if ( + // @ts-expect-error: TODO fix interactionState + !info.interactionState || // first event of before an animation (e.g. clicking the +/- buttons of the navigation controls, gives the target + // @ts-expect-error: TODO fix interactionState + (info.interactionState && !info.interactionState.inTransition) // Any event not part of a animation (mouse panning or zooming) + ) { + if (info.viewState.zoom >= labelsZoomThreshold && !labelsVisible) { + setLabelsVisible(true); + } else if (info.viewState.zoom < labelsZoomThreshold && labelsVisible) { + setLabelsVisible(false); + } + setShowTooltip(info.viewState.zoom >= tooltipZoomThreshold); + setShowLineFlow(info.viewState.zoom >= arrowsZoomThreshold); } - setShowTooltip(info.viewState.zoom >= props.tooltipZoomThreshold); - setShowLineFlow(info.viewState.zoom >= props.arrowsZoomThreshold); } - } - function renderTooltip() { - return ( - tooltip && - tooltip.visible && - !shouldDisableToolTip && - //As of now only LINE tooltip is implemented, the following condition is to be removed or tweaked once other types of line tooltip are implemented - tooltip.equipmentType === EQUIPMENT_TYPES.LINE && ( -
- {props.renderPopover(tooltip.equipmentId, divRef.current)} -
- ) - ); - } + function renderTooltip() { + return ( + tooltip && + tooltip.visible && + !shouldDisableToolTip && + //As of now only LINE tooltip is implemented, the following condition is to be removed or tweaked once other types of line tooltip are implemented + tooltip.equipmentType === EQUIPMENT_TYPES.LINE && ( +
+ {tooltip.equipmentId && divRef.current && renderPopover(tooltip.equipmentId, divRef.current)} +
+ ) + ); + } - function onClickHandler(info, event, network) { - const leftButton = event.originalEvent.button === MOUSE_EVENT_BUTTON_LEFT; - const rightButton = event.originalEvent.button === MOUSE_EVENT_BUTTON_RIGHT; - if ( - info.layer && - info.layer.id.startsWith(SUBSTATION_LAYER_PREFIX) && - info.object && - (info.object.substationId || info.object.voltageLevels) // is a voltage level marker, or a substation text + function onClickHandler( + info: PickingInfo, + event: mapboxgl.MapLayerMouseEvent | maplibregl.MapLayerMouseEvent, + network: MapEquipments ) { - let idVl; - let idSubstation; - if (info.object.substationId) { - idVl = info.object.id; - } else if (info.object.voltageLevels) { - if (info.object.voltageLevels.length === 1) { - let idS = info.object.voltageLevels[0].substationId; - let substation = network.getSubstation(idS); - if (substation && substation.voltageLevels.length > 1) { - idSubstation = idS; + const leftButton = event.originalEvent.button === MOUSE_EVENT_BUTTON_LEFT; + const rightButton = event.originalEvent.button === MOUSE_EVENT_BUTTON_RIGHT; + if ( + info.layer && + info.layer.id.startsWith(SUBSTATION_LAYER_PREFIX) && + info.object && + (isSubstation(info.object) || isVoltageLevel(info.object)) // is a voltage level marker, or a substation text + ) { + let idVl; + let idSubstation; + if (isVoltageLevel(info.object)) { + idVl = info.object.id; + } else if (isSubstation(info.object)) { + if (info.object.voltageLevels.length === 1) { + const idS = info.object.voltageLevels[0].substationId; + const substation = network.getSubstation(idS); + if (substation && substation.voltageLevels.length > 1) { + idSubstation = idS; + } else { + idVl = info.object.voltageLevels[0].id; + } } else { - idVl = info.object.voltageLevels[0].id; + idSubstation = info.object.voltageLevels[0].substationId; } - } else { - idSubstation = info.object.voltageLevels[0].substationId; } - } - if (idVl !== undefined) { - if (props.onSubstationClick && leftButton) { - props.onSubstationClick(idVl); - } else if (props.onVoltageLevelMenuClick && rightButton) { - props.onVoltageLevelMenuClick( - network.getVoltageLevel(idVl), - event.originalEvent.x, - event.originalEvent.y - ); + if (idVl !== undefined) { + if (onSubstationClick && leftButton) { + onSubstationClick(idVl); + } else if (onVoltageLevelMenuClick && rightButton) { + onVoltageLevelMenuClick( + network.getVoltageLevel(idVl)!, + event.originalEvent.x, + event.originalEvent.y + ); + } + } + if (idSubstation !== undefined) { + if (onSubstationClickChooseVoltageLevel && leftButton) { + onSubstationClickChooseVoltageLevel(idSubstation, event.originalEvent.x, event.originalEvent.y); + } else if (onSubstationMenuClick && rightButton) { + onSubstationMenuClick( + network.getSubstation(idSubstation)!, + event.originalEvent.x, + event.originalEvent.y + ); + } } } - if (idSubstation !== undefined) { - if (props.onSubstationClickChooseVoltageLevel && leftButton) { - props.onSubstationClickChooseVoltageLevel( - idSubstation, - event.originalEvent.x, - event.originalEvent.y - ); - } else if (props.onSubstationMenuClick && rightButton) { - props.onSubstationMenuClick( - network.getSubstation(idSubstation), - event.originalEvent.x, - event.originalEvent.y - ); + if ( + rightButton && + info.layer && + info.layer.id.startsWith(LINE_LAYER_PREFIX) && + info.object && + isLine(info.object) + ) { + // picked line properties are retrieved from network data and not from pickable object infos, + // because pickable object infos might not be up to date + const line = network.getLine(info.object.id); + const tieLine = network.getTieLine(info.object.id); + const hvdcLine = network.getHvdcLine(info.object.id); + + const equipment = line || tieLine || hvdcLine; + if (equipment) { + const menuClickFunction = + equipment === line + ? onLineMenuClick + : equipment === tieLine + ? onTieLineMenuClick + : onHvdcLineMenuClick; + + menuClickFunction(equipment, event.originalEvent.x, event.originalEvent.y); } } } - if ( - rightButton && - info.layer && - info.layer.id.startsWith(LINE_LAYER_PREFIX) && - info.object && - info.object.id && - info.object.voltageLevelId1 && - info.object.voltageLevelId2 - ) { - // picked line properties are retrieved from network data and not from pickable object infos, - // because pickable object infos might not be up to date - const line = network.getLine(info.object.id); - const tieLine = network.getTieLine(info.object.id); - const hvdcLine = network.getHvdcLine(info.object.id); - - const equipment = line || tieLine || hvdcLine; - if (equipment) { - const menuClickFunction = - equipment === line - ? props.onLineMenuClick - : equipment === tieLine - ? props.onTieLineMenuClick - : props.onHvdcLineMenuClick; - - menuClickFunction(equipment, event.originalEvent.x, event.originalEvent.y); - } + + function onMapContextMenu(event: mapboxgl.MapLayerMouseEvent | maplibregl.MapLayerMouseEvent) { + const info = + deckRef.current && + deckRef.current.pickObject({ + x: event.point.x, + y: event.point.y, + radius: PICKING_RADIUS, + }); + info && mapEquipments && onClickHandler(info, event, mapEquipments); } - } - function onMapContextMenu(event) { - const info = - deckRef.current && - deckRef.current.pickObject({ - x: event.point.x, - y: event.point.y, - radius: PICKING_RADIUS, - }); - info && onClickHandler(info, event, props.mapEquipments); - } + function cursorHandler() { + return isDragging ? 'grabbing' : cursorType; + } - function cursorHandler() { - return isDragging ? 'grabbing' : cursorType; - } + const layers = []; + + if (readyToDisplaySubstations) { + layers.push( + new SubstationLayer({ + id: SUBSTATION_LAYER_PREFIX, + data: mapEquipments?.substations, + network: mapEquipments, + geoData: geoData, + getNominalVoltageColor: getNominalVoltageColor, + filteredNominalVoltages: filteredNominalVoltages, + labelsVisible: labelsVisible, + labelColor: foregroundNeutralColor, + labelSize: LABEL_SIZE, + pickable: true, + onHover: ({ object }) => { + setCursorType(object ? 'pointer' : 'grab'); + }, + getNameOrId: getNameOrId, + }) + ); + } - const layers = []; - - if (readyToDisplaySubstations) { - layers.push( - new SubstationLayer({ - id: SUBSTATION_LAYER_PREFIX, - data: props.mapEquipments?.substations, - network: props.mapEquipments, - geoData: props.geoData, - getNominalVoltageColor: getNominalVoltageColor, - filteredNominalVoltages: props.filteredNominalVoltages, - labelsVisible: labelsVisible, - labelColor: foregroundNeutralColor, - labelSize: LABEL_SIZE, - pickable: true, - onHover: ({ object }) => { - setCursorType(object ? 'pointer' : 'grab'); - }, - getNameOrId: getNameOrId, - }) + if (readyToDisplayLines) { + layers.push( + new LineLayer({ + areFlowsValid: areFlowsValid, + id: LINE_LAYER_PREFIX, + data: mapEquipmentsLines, + network: mapEquipments, + updatedLines: updatedLines, + geoData: geoData, + getNominalVoltageColor: getNominalVoltageColor, + disconnectedLineColor: foregroundNeutralColor, + filteredNominalVoltages: filteredNominalVoltages, + lineFlowMode: lineFlowMode, + showLineFlow: visible && showLineFlow, + lineFlowColorMode: lineFlowColorMode, + lineFlowAlertThreshold: lineFlowAlertThreshold, + lineFullPath: geoData.linePositionsById.size > 0 && lineFullPath, + lineParallelPath: lineParallelPath, + labelsVisible: labelsVisible, + labelColor: foregroundNeutralColor, + labelSize: LABEL_SIZE, + pickable: true, + onHover: ({ object: lineObject, x, y }: PickingInfo) => { + if (lineObject) { + setCursorType('pointer'); + setTooltip({ + equipmentId: lineObject?.id, + equipmentType: (lineObject as EquimentLine)?.equipmentType, + pointerX: x, + pointerY: y, + visible: showTooltip, + }); + } else { + setCursorType('grab'); + setTooltip(null); + } + }, + }) + ); + } + + const initialViewState = { + longitude: initialPosition[0], + latitude: initialPosition[1], + zoom: initialZoom, + maxZoom: 14, + pitch: 0, + bearing: 0, + }; + + const renderOverlay = () => ( + ); - } - if (readyToDisplayLines) { - layers.push( - new LineLayer({ - areFlowsValid: props.areFlowsValid, - id: LINE_LAYER_PREFIX, - data: mapEquipmentsLines, - network: props.mapEquipments, - updatedLines: props.updatedLines, - geoData: props.geoData, - getNominalVoltageColor: getNominalVoltageColor, - disconnectedLineColor: foregroundNeutralColor, - filteredNominalVoltages: props.filteredNominalVoltages, - lineFlowMode: props.lineFlowMode, - showLineFlow: props.visible && showLineFlow, - lineFlowColorMode: props.lineFlowColorMode, - lineFlowAlertThreshold: props.lineFlowAlertThreshold, - lineFullPath: props.geoData.linePositionsById.size > 0 && props.lineFullPath, - lineParallelPath: props.lineParallelPath, - labelsVisible: labelsVisible, - labelColor: foregroundNeutralColor, - labelSize: LABEL_SIZE, - pickable: true, - onHover: ({ object, x, y }) => { - if (object) { - setCursorType('pointer'); - const lineObject = object?.line ?? object; - setTooltip({ - equipmentId: lineObject?.id, - equipmentType: lineObject?.equipmentType, - pointerX: x, - pointerY: y, - visible: showTooltip, - }); + useEffect(() => { + mapRef.current?.resize(); + }, [mapRef, triggerMapResizeOnChange]); + + const getMapStyle = (mapLibrary: MapLibrary, mapTheme: MapTheme) => { + switch (mapLibrary) { + case MAPBOX: + if (mapTheme === LIGHT) { + return 'mapbox://styles/mapbox/light-v9'; } else { - setCursorType('grab'); - setTooltip(null); + return 'mapbox://styles/mapbox/dark-v9'; } - }, - }) - ); - } - - const initialViewState = { - longitude: props.initialPosition[0], - latitude: props.initialPosition[1], - zoom: props.initialZoom, - maxZoom: 14, - pitch: 0, - bearing: 0, - }; - - const renderOverlay = () => ( - - ); - - useEffect(() => { - mapRef.current?.resize(); - }, [props.triggerMapResizeOnChange]); - - const getMapStyle = (mapLibrary, mapTheme) => { - switch (mapLibrary) { - case MAPBOX: - if (mapTheme === LIGHT) { - return 'mapbox://styles/mapbox/light-v9'; - } else { - return 'mapbox://styles/mapbox/dark-v9'; - } - case CARTO: - if (mapTheme === LIGHT) { + case CARTO: + if (mapTheme === LIGHT) { + return 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; + } else { + return 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; + } + case CARTO_NOLABEL: + if (mapTheme === LIGHT) { + return 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json'; + } else { + return 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json'; + } + default: return 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; - } else { - return 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; - } - case CARTO_NOLABEL: - if (mapTheme === LIGHT) { - return 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json'; - } else { - return 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json'; - } - default: - return 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; - } - }; - - const mapStyle = useMemo(() => getMapStyle(props.mapLibrary, props.mapTheme), [props.mapLibrary, props.mapTheme]); - - const mapLib = - props.mapLibrary === MAPBOX - ? mToken && { - key: 'mapboxgl', - mapLib: mapboxgl, - mapboxAccessToken: mToken, - } - : { - key: 'maplibregl', - mapLib: maplibregl, - }; - - // because the mapLib prop of react-map-gl is not reactive, we need to - // unmount/mount the Map with 'key', so we need also to reset all state - // associated with uncontrolled state of the map - useEffect(() => { - setCentered(INITIAL_CENTERED); - }, [mapLib?.key]); - - const onUpdate = useCallback(() => { - onPolygonChanged(getPolygonFeatures()); - onDrawEvent(DRAW_EVENT.UPDATE); - }, [onDrawEvent, onPolygonChanged]); - - const onCreate = useCallback(() => { - onPolygonChanged(getPolygonFeatures()); - onDrawEvent(DRAW_EVENT.CREATE); - }, [onDrawEvent, onPolygonChanged]); - const getSelectedLines = useCallback(() => { - const polygonFeatures = getPolygonFeatures(); - const polygonCoordinates = polygonFeatures?.geometry; - if (!polygonCoordinates || polygonCoordinates.coordinates < 3) { - return []; - } - //for each line, check if it is in the polygon - const selectedLines = getSelectedLinesInPolygon( - props.mapEquipments, - mapEquipmentsLines, - props.geoData, - polygonCoordinates - ); - return selectedLines.filter((line) => { - return props.filteredNominalVoltages.some((nv) => { - return ( - nv === props.mapEquipments.getVoltageLevel(line.voltageLevelId1).nominalV || - nv === props.mapEquipments.getVoltageLevel(line.voltageLevelId2).nominalV - ); + } + }; + + const mapStyle = useMemo(() => getMapStyle(mapLibrary, mapTheme), [mapLibrary, mapTheme]); + + const key = mapLibrary === MAPBOX && mToken ? 'mapboxgl' : 'maplibregl'; + + const mapLib = + mapLibrary === MAPBOX + ? mToken && { + mapLib: mapboxgl, + mapboxAccessToken: mToken, + } + : { + mapLib: maplibregl, + }; + + // because the mapLib prop of react-map-gl is not reactive, we need to + // unmount/mount the Map with 'key', so we need also to reset all state + // associated with uncontrolled state of the map + useEffect(() => { + setCentered(INITIAL_CENTERED); + }, [key]); + + const onUpdate = useCallback(() => { + onPolygonChanged(getPolygonFeatures()); + onDrawEvent(DRAW_EVENT.UPDATE); + }, [onDrawEvent, onPolygonChanged]); + + const onCreate = useCallback(() => { + onPolygonChanged(getPolygonFeatures()); + onDrawEvent(DRAW_EVENT.CREATE); + }, [onDrawEvent, onPolygonChanged]); + const getSelectedLines = useCallback(() => { + const polygonFeatures = getPolygonFeatures(); + const polygonCoordinates = polygonFeatures?.geometry; + if ( + !polygonCoordinates || + polygonCoordinates.type !== 'Polygon' || + polygonCoordinates.coordinates[0].length < 3 + ) { + return []; + } + //for each line, check if it is in the polygon + const selectedLines = getSelectedLinesInPolygon( + mapEquipments, + mapEquipmentsLines, + geoData, + polygonCoordinates as Polygon + ); + return selectedLines.filter((line: Line) => { + return filteredNominalVoltages!.some((nv) => { + return ( + nv === mapEquipments!.getVoltageLevel(line.voltageLevelId1)!.nominalV || + nv === mapEquipments!.getVoltageLevel(line.voltageLevelId2)!.nominalV + ); + }); }); - }); - }, [props.mapEquipments, mapEquipmentsLines, props.geoData, props.filteredNominalVoltages]); + }, [mapEquipments, mapEquipmentsLines, geoData, filteredNominalVoltages]); - const getSelectedSubstations = useCallback(() => { - const substations = getSubstationsInPolygon(getPolygonFeatures(), props.mapEquipments, props.geoData); - return ( - substations.filter((substation) => { - return substation.voltageLevels.some((vl) => props.filteredNominalVoltages.includes(vl.nominalV)); - }) ?? [] + const getSelectedSubstations = useCallback(() => { + const substations = getSubstationsInPolygon(getPolygonFeatures(), mapEquipments, geoData); + if (filteredNominalVoltages === null) { + return substations; + } + return ( + substations.filter((substation) => { + return substation.voltageLevels.some((vl) => filteredNominalVoltages.includes(vl.nominalV)); + }) ?? [] + ); + }, [mapEquipments, geoData, filteredNominalVoltages]); + + useImperativeHandle( + ref, + () => ({ + getSelectedSubstations, + getSelectedLines, + cleanDraw() { + //because deleteAll does not trigger a update of the polygonFeature callback + getMapDrawer()?.deleteAll(); + onPolygonChanged(getPolygonFeatures()); + onDrawEvent(DRAW_EVENT.DELETE); + }, + getMapDrawer, + }), + [onPolygonChanged, getSelectedSubstations, getSelectedLines, onDrawEvent] ); - }, [props.mapEquipments, props.geoData, props.filteredNominalVoltages]); - - useImperativeHandle( - ref, - () => ({ - getSelectedSubstations, - getSelectedLines, - cleanDraw() { - //because deleteAll does not trigger a update of the polygonFeature callback - getMapDrawer()?.deleteAll(); - onPolygonChanged(getPolygonFeatures()); - onDrawEvent(DRAW_EVENT.DELETE); - }, - getMapDrawer, - }), - [onPolygonChanged, getSelectedSubstations, getSelectedLines, onDrawEvent] - ); - - const onDelete = useCallback(() => { - onPolygonChanged(getPolygonFeatures()); - onDrawEvent(DRAW_EVENT.DELETE); - }, [onPolygonChanged, onDrawEvent]); - - return ( - mapLib && ( - setDragging(true)} - onDragEnd={() => setDragging(false)} - onContextMenu={onMapContextMenu} - > - {props.displayOverlayLoader && renderOverlay()} - {props.isManualRefreshBackdropDisplayed && ( - - - - )} - { - onClickHandler(info, event.srcEvent, props.mapEquipments); - }} - onAfterRender={onAfterRender} // TODO simplify this - layers={layers} - pickingRadius={PICKING_RADIUS} - /> - {showTooltip && renderTooltip()} - {/* visualizePitch true makes the compass reset the pitch when clicked in addition to visualizing it */} - - { - props.onDrawPolygonModeActive(polygon_draw); - }} - onCreate={onCreate} - onUpdate={onUpdate} - onDelete={onDelete} - /> - - ) - ); -}); -NetworkMap.defaultProps = { - areFlowsValid: true, - arrowsZoomThreshold: 7, - centerOnSubstation: null, - disabled: false, - displayOverlayLoader: false, - filteredNominalVoltages: null, - geoData: null, - initialPosition: [0, 0], - initialZoom: 5, - isManualRefreshBackdropDisplayed: false, - labelsZoomThreshold: 9, - lineFlowAlertThreshold: 100, - lineFlowColorMode: LineFlowColorMode.NOMINAL_VOLTAGE, - lineFlowHidden: true, - lineFlowMode: LineFlowMode.FEEDERS, - lineFullPath: true, - lineParallelPath: true, - mapBoxToken: null, - mapEquipments: null, - mapLibrary: CARTO, - tooltipZoomThreshold: 7, - mapTheme: DARK, - updatedLines: [], - useName: true, - visible: true, - shouldDisableToolTip: false, - locateSubStationZoomLevel: DEFAULT_LOCATE_SUBSTATION_ZOOM_LEVEL, - - onSubstationClick: () => {}, - onSubstationClickChooseVoltageLevel: () => {}, - onSubstationMenuClick: () => {}, - onVoltageLevelMenuClick: () => {}, - onLineMenuClick: () => {}, - onTieLineMenuClick: () => {}, - onHvdcLineMenuClick: () => {}, - onManualRefreshClick: () => {}, - renderPopover: (eId) => { - return eId; - }, - onDrawPolygonModeActive: () => {}, - onPolygonChanged: () => {}, - onDrawEvent: () => {}, -}; + const onDelete = useCallback(() => { + onPolygonChanged(getPolygonFeatures()); + onDrawEvent(DRAW_EVENT.DELETE); + }, [onPolygonChanged, onDrawEvent]); -NetworkMap.propTypes = { - disabled: PropTypes.bool, - geoData: PropTypes.instanceOf(GeoData), - mapBoxToken: PropTypes.string, - mapEquipments: PropTypes.instanceOf(MapEquipments), - mapLibrary: PropTypes.oneOf([CARTO, CARTO_NOLABEL, MAPBOX]), - mapTheme: PropTypes.oneOf([LIGHT, DARK]), - - areFlowsValid: PropTypes.bool, - arrowsZoomThreshold: PropTypes.number, - centerOnSubstation: PropTypes.any, - displayOverlayLoader: PropTypes.bool, - filteredNominalVoltages: PropTypes.array, - initialPosition: PropTypes.arrayOf(PropTypes.number), - initialZoom: PropTypes.number, - isManualRefreshBackdropDisplayed: PropTypes.bool, - labelsZoomThreshold: PropTypes.number, - lineFlowAlertThreshold: PropTypes.number, - lineFlowColorMode: PropTypes.oneOf(Object.values(LineFlowColorMode)), - lineFlowHidden: PropTypes.bool, - lineFlowMode: PropTypes.oneOf(Object.values(LineFlowMode)), - lineFullPath: PropTypes.bool, - lineParallelPath: PropTypes.bool, - renderPopover: PropTypes.func, - tooltipZoomThreshold: PropTypes.number, - // With mapboxgl v2 (not a problem with maplibre), we need to call - // map.resize() when the parent size has changed, otherwise the map is not - // redrawn. It seems like this is autodetected when the browser window is - // resized, but not for programmatic resizes of the parent. For now in our - // app, only study display mode resizes programmatically - // use this prop to make the map resize when needed, each time this prop changes, map.resize() is trigged - triggerMapResizeOnChange: PropTypes.any, - updatedLines: PropTypes.array, - useName: PropTypes.bool, - visible: PropTypes.bool, - shouldDisableToolTip: PropTypes.bool, - locateSubStationZoomLevel: PropTypes.number, - onHvdcLineMenuClick: PropTypes.func, - onLineMenuClick: PropTypes.func, - onTieLineMenuClick: PropTypes.func, - onManualRefreshClick: PropTypes.func, - onSubstationClick: PropTypes.func, - onSubstationClickChooseVoltageLevel: PropTypes.func, - onSubstationMenuClick: PropTypes.func, - onVoltageLevelMenuClick: PropTypes.func, - onDrawPolygonModeActive: PropTypes.func, - onPolygonChanged: PropTypes.func, - onDrawEvent: PropTypes.func, -}; + return ( + mapLib && ( + setDragging(true)} + onDragEnd={() => setDragging(false)} + onContextMenu={onMapContextMenu} + mapLib={mapLib.mapLib as MapLib} + > + {displayOverlayLoader && renderOverlay()} + {isManualRefreshBackdropDisplayed && ( + + + + )} + { + onClickHandler( + info, + event.srcEvent as mapboxgl.MapLayerMouseEvent | maplibregl.MapLayerMouseEvent, + mapEquipments! + ); + }} + onAfterRender={onAfterRender} // TODO simplify this + layers={layers} + pickingRadius={PICKING_RADIUS} + /> + {showTooltip && renderTooltip()} + {/* visualizePitch true makes the compass reset the pitch when clicked in addition to visualizing it */} + + { + onDrawPolygonModeActive(polygon_draw); + }} + onCreate={onCreate} + onUpdate={onUpdate} + onDelete={onDelete} + /> + + ) + ); + } +); export default memo(NetworkMap); -function getSubstationsInPolygon(features, mapEquipments, geoData) { +function getSubstationsInPolygon( + features: Partial, // Feature from geojson + mapEquipments: MapEquipments | null, + geoData: GeoData | null +) { const polygonCoordinates = features?.geometry; - if (!polygonCoordinates || polygonCoordinates.coordinates < 3) { + if ( + !geoData || + !polygonCoordinates || + polygonCoordinates.type !== 'Polygon' || + polygonCoordinates.coordinates[0].length < 3 + ) { return []; } //get the list of substation @@ -732,10 +813,15 @@ function getSubstationsInPolygon(features, mapEquipments, geoData) { }); } -function getSelectedLinesInPolygon(network, lines, geoData, polygonCoordinates) { +function getSelectedLinesInPolygon( + network: MapEquipments | null, + lines: Line[], + geoData: GeoData | null, + polygonCoordinates: Polygon +) { return lines.filter((line) => { try { - const linePos = geoData.getLinePositions(network, line); + const linePos = network ? geoData?.getLinePositions(network, line) : null; if (!linePos) { return false; }