diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b21d035c..3b200f36 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -8,7 +8,6 @@ import { useEffect, useRef } from 'react'; import { createTheme, StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import { GeoData, NetworkMap, NetworkMapRef } from '../../src'; -import { Equipment } from '../../src/components/network-map-viewer/network/map-equipments'; import { addNadToDemo, addSldToDemo } from './diagram-viewers/add-diagrams'; import DemoMapEquipments from './map-viewer/demo-map-equipments'; @@ -16,6 +15,7 @@ import sposdata from './map-viewer/data/spos.json'; import lposdata from './map-viewer/data/lpos.json'; import smapdata from './map-viewer/data/smap.json'; import lmapdata from './map-viewer/data/lmap.json'; +import { Equipment } from '../../src/components/network-map-viewer/utils/equipment-types'; export default function App() { const INITIAL_ZOOM = 9; diff --git a/src/components/network-map-viewer/network/geo-data.js b/src/components/network-map-viewer/network/geo-data.ts similarity index 80% rename from src/components/network-map-viewer/network/geo-data.js rename to src/components/network-map-viewer/network/geo-data.ts index 821572bb..050479ff 100644 --- a/src/components/network-map-viewer/network/geo-data.js +++ b/src/components/network-map-viewer/network/geo-data.ts @@ -8,33 +8,49 @@ import { computeDestinationPoint, getGreatCircleBearing, getRhumbLineBearing } from 'geolib'; import cheapRuler from 'cheap-ruler'; import { ArrowDirection } from './layers/arrow-layer'; +import { Line, LonLat } from '../utils/equipment-types'; +import { MapEquipments } from './map-equipments'; -const substationPositionByIdIndexer = (map, substation) => { +export type Coordinate = { + lon: number; + lat: number; +}; + +export type SubstationPosition = { + id: string; + coordinate: Coordinate; +}; + +export type LinePosition = { + id: string; + coordinates: Coordinate[]; +}; + +const substationPositionByIdIndexer = (map: Map, substation: SubstationPosition) => { map.set(substation.id, substation.coordinate); return map; }; -const linePositionByIdIndexer = (map, line) => { +const linePositionByIdIndexer = (map: Map, line: LinePosition) => { map.set(line.id, line.coordinates); return map; }; export class GeoData { - substationPositionsById = new Map(); - - linePositionsById = new Map(); + substationPositionsById = new Map(); + linePositionsById = new Map(); - constructor(substationPositionsById, linePositionsById) { + constructor(substationPositionsById: Map, linePositionsById: Map) { this.substationPositionsById = substationPositionsById; this.linePositionsById = linePositionsById; } - setSubstationPositions(positions) { + setSubstationPositions(positions: SubstationPosition[]) { // index positions by substation id this.substationPositionsById = positions.reduce(substationPositionByIdIndexer, new Map()); } - updateSubstationPositions(substationIdsToUpdate, fetchedPositions) { + updateSubstationPositions(substationIdsToUpdate: string[], fetchedPositions: SubstationPosition[]) { fetchedPositions.forEach((pos) => this.substationPositionsById.set(pos.id, pos.coordinate)); // If a substation position is requested but not present in the fetched results, we delete its position. // It allows to cancel the position of a substation when the server can't situate it anymore after a network modification (for example a line deletion). @@ -43,7 +59,7 @@ export class GeoData { .forEach((id) => this.substationPositionsById.delete(id)); } - getSubstationPosition(substationId) { + getSubstationPosition(substationId: string): LonLat { const position = this.substationPositionsById.get(substationId); if (!position) { console.warn(`Position not found for ${substationId}`); @@ -52,12 +68,12 @@ export class GeoData { return [position.lon, position.lat]; } - setLinePositions(positions) { + setLinePositions(positions: LinePosition[]) { // index positions by line id this.linePositionsById = positions.reduce(linePositionByIdIndexer, new Map()); } - updateLinePositions(lineIdsToUpdate, fetchedPositions) { + updateLinePositions(lineIdsToUpdate: string[], fetchedPositions: LinePosition[]) { fetchedPositions.forEach((pos) => { this.linePositionsById.set(pos.id, pos.coordinates); }); @@ -72,7 +88,7 @@ export class GeoData { /** * Get line positions always ordered from side 1 to side 2. */ - getLinePositions(network, line, detailed = true) { + getLinePositions(network: MapEquipments, line: Line, detailed = true): LonLat[] { const voltageLevel1 = network.getVoltageLevel(line.voltageLevelId1); if (!voltageLevel1) { throw new Error(`Voltage level side 1 '${line.voltageLevelId1}' not found`); @@ -101,7 +117,7 @@ export class GeoData { const linePositions = this.linePositionsById.get(line.id); // Is there any position for this line ? if (linePositions) { - const positions = new Array(linePositions.length); + const positions = new Array(linePositions.length); for (const [index, position] of linePositions.entries()) { positions[index] = [position.lon, position.lat]; @@ -114,9 +130,9 @@ export class GeoData { return [substationPosition1, substationPosition2]; } - getLineDistances(positions) { + getLineDistances(positions: LonLat[]) { if (positions !== null && positions.length > 1) { - let cumulativeDistanceArray = [0]; + const cumulativeDistanceArray = [0]; let cumulativeDistance = 0; let segmentDistance; let ruler; @@ -136,13 +152,13 @@ export class GeoData { * along with the remaining distance to travel on this segment to be at the exact wanted distance * (implemented using a binary search) */ - findSegment(positions, cumulativeDistances, wantedDistance) { + findSegment(positions: LonLat[], cumulativeDistances: number[], wantedDistance: number) { let lowerBound = 0; let upperBound = cumulativeDistances.length - 1; let middlePoint; while (lowerBound + 1 !== upperBound) { middlePoint = Math.floor((lowerBound + upperBound) / 2); - let middlePointDistance = cumulativeDistances[middlePoint]; + const middlePointDistance = cumulativeDistances[middlePoint]; if (middlePointDistance <= wantedDistance) { lowerBound = middlePoint; } else { @@ -151,21 +167,21 @@ export class GeoData { } return { idx: lowerBound, - segment: positions.slice(lowerBound, lowerBound + 2), + segment: positions.slice(lowerBound, lowerBound + 2) as [LonLat, LonLat], remainingDistance: wantedDistance - cumulativeDistances[lowerBound], }; } labelDisplayPosition( - positions, - cumulativeDistances, - arrowPosition, - arrowDirection, - lineParallelIndex, - lineAngle, - proximityAngle, - distanceBetweenLines, - proximityFactor + positions: LonLat[], + cumulativeDistances: number[], + arrowPosition: number, + arrowDirection: ArrowDirection, + lineParallelIndex: number, + lineAngle: number, + proximityAngle: number, + distanceBetweenLines: number, + proximityFactor: number ) { if (arrowPosition > 1 || arrowPosition < 0) { throw new Error('Proportional position value incorrect: ' + arrowPosition); @@ -177,7 +193,7 @@ export class GeoData { ) { return null; } - let lineDistance = cumulativeDistances[cumulativeDistances.length - 1]; + const lineDistance = cumulativeDistances[cumulativeDistances.length - 1]; let wantedDistance = lineDistance * arrowPosition; if (cumulativeDistances.length === 2) { @@ -187,7 +203,7 @@ export class GeoData { wantedDistance = wantedDistance - 2 * distanceBetweenLines * arrowPosition * proximityFactor; } - let goodSegment = this.findSegment(positions, cumulativeDistances, wantedDistance); + const goodSegment = this.findSegment(positions, cumulativeDistances, wantedDistance); // We don't have the exact same distance calculation as in the arrow shader, so take some margin: // we move the label a little bit on the flat side of the arrow so that at least it stays @@ -206,9 +222,9 @@ export class GeoData { default: throw new Error('impossible'); } - let remainingDistance = goodSegment.remainingDistance * multiplier; + const remainingDistance = goodSegment.remainingDistance * multiplier; - let angle = this.getMapAngle(goodSegment.segment[0], goodSegment.segment[1]); + const angle = this.getMapAngle(goodSegment.segment[0], goodSegment.segment[1]); const neededOffset = this.getLabelOffset(angle, 20, arrowDirection); const position = { @@ -252,8 +268,8 @@ export class GeoData { return position; } - getLabelOffset(angle, offsetDistance, arrowDirection) { - let radiantAngle = (-angle + 90) / (180 / Math.PI); + getLabelOffset(angle: number, offsetDistance: number, arrowDirection: ArrowDirection): [number, number] { + const radiantAngle = (-angle + 90) / (180 / Math.PI); let direction = 0; switch (arrowDirection) { case ArrowDirection.FROM_SIDE_2_TO_SIDE_1: @@ -276,11 +292,11 @@ export class GeoData { } //returns the angle between point1 and point2 in degrees [0-360) - getMapAngle(point1, point2) { + getMapAngle(point1: LonLat, point2: LonLat) { // We don't have the exact same angle calculation as in the arrow shader, and this // seems to give more approaching results let angle = getRhumbLineBearing(point1, point2); - let angle2 = getGreatCircleBearing(point1, point2); + const angle2 = getGreatCircleBearing(point1, point2); const coeff = 0.1; angle = coeff * angle + (1 - coeff) * angle2; return angle; diff --git a/src/components/network-map-viewer/network/layers/arrow-layer.js b/src/components/network-map-viewer/network/layers/arrow-layer.ts similarity index 73% rename from src/components/network-map-viewer/network/layers/arrow-layer.js rename to src/components/network-map-viewer/network/layers/arrow-layer.ts index 8e9f8b15..4c22944c 100644 --- a/src/components/network-map-viewer/network/layers/arrow-layer.js +++ b/src/components/network-map-viewer/network/layers/arrow-layer.ts @@ -4,31 +4,61 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Layer, project32, picking } from '@deck.gl/core'; +import { picking, project32 } from '@deck.gl/core'; import GL from '@luma.gl/constants'; -import { Model, Geometry, Texture2D, FEATURES, hasFeatures, isWebGL2 } from '@luma.gl/core'; - +import { FEATURES, Geometry, hasFeatures, isWebGL2, Model, Texture2D } from '@luma.gl/core'; import vs from './arrow-layer-vertex.vert?raw'; import fs from './arrow-layer-fragment.frag?raw'; +import { Accessor, Color, Layer, LayerContext, LayerProps, Position, Texture, UpdateParameters } from 'deck.gl'; +import { Line } from '../../utils/equipment-types'; +import { type UniformValues } from 'maplibre-gl'; -const DEFAULT_COLOR = [0, 0, 0, 255]; +const DEFAULT_COLOR = [0, 0, 0, 255] satisfies Color; // this value has to be consistent with the one in vertex shader const MAX_LINE_POINT_COUNT = 2 ** 15; -export const ArrowDirection = { - NONE: 'none', - FROM_SIDE_1_TO_SIDE_2: 'fromSide1ToSide2', - FROM_SIDE_2_TO_SIDE_1: 'fromSide2ToSide1', +export enum ArrowDirection { + NONE = 'none', + FROM_SIDE_1_TO_SIDE_2 = 'fromSide1ToSide2', + FROM_SIDE_2_TO_SIDE_1 = 'fromSide2ToSide1', +} + +export type Arrow = { + line: Line; + distance: number; }; +export type LayerDataSource = DataType[]; + +type _ArrowLayerProps = { + data: Arrow[]; + sizeMinPixels?: number; + sizeMaxPixels?: number; + getDistance: Accessor; + getLine: (arrow: Arrow) => Line; + getLinePositions: (line: Line) => Position[]; + getSize?: Accessor; + getColor?: Accessor; + getSpeedFactor?: Accessor; + getDirection?: Accessor; + animated?: boolean; + getLineParallelIndex?: Accessor; + getLineAngles?: Accessor; + getDistanceBetweenLines?: Accessor; + maxParallelOffset?: number; + minParallelOffset?: number; + opacity?: number; +} & LayerProps; + +type ArrowLayerProps = _ArrowLayerProps & LayerProps; + const defaultProps = { sizeMinPixels: { type: 'number', min: 0, value: 0 }, // min size in pixels sizeMaxPixels: { type: 'number', min: 0, value: Number.MAX_SAFE_INTEGER }, // max size in pixels - - getDistance: { type: 'accessor', value: (arrow) => arrow.distance }, - getLine: { type: 'accessor', value: (arrow) => arrow.line }, - getLinePositions: { type: 'accessor', value: (line) => line.positions }, + getDistance: { type: 'accessor', value: (arrow: Arrow) => arrow.distance }, + getLine: { type: 'accessor', value: (arrow: Arrow) => arrow.line }, + getLinePositions: { type: 'accessor', value: (line: Line) => line.positions }, getSize: { type: 'accessor', value: 1 }, getColor: { type: 'accessor', value: DEFAULT_COLOR }, getSpeedFactor: { type: 'accessor', value: 1.0 }, @@ -42,6 +72,13 @@ const defaultProps = { opacity: { type: 'number', value: 1.0 }, }; +type LineAttributes = { + distance: number; + positionsTextureOffset: number; + distancesTextureOffset: number; + pointCount: number; +}; + /** * A layer that draws arrows over the lines between voltage levels. The arrows are drawn on a direct line * or with a parallel offset. The initial point is also shifted to coincide with the fork line ends. @@ -52,12 +89,26 @@ const defaultProps = { * maxParallelOffset: max pixel distance * minParallelOffset: min pixel distance */ -export class ArrowLayer extends Layer { +export class ArrowLayer extends Layer> { + static layerName = 'ArrowLayer'; + static defaultProps = defaultProps; + + declare state: { + linePositionsTexture: Texture; + lineDistancesTexture: Texture; + lineAttributes: Map; + model?: Model; + timestamp: number; + stop: boolean; + maxTextureSize: number; + webgl2: boolean; + }; + getShaders() { return super.getShaders({ vs, fs, modules: [project32, picking] }); } - getArrowLineAttributes(arrow) { + getArrowLineAttributes(arrow: Arrow): LineAttributes { const line = this.props.getLine(arrow); if (!line) { throw new Error('Invalid line'); @@ -80,9 +131,9 @@ export class ArrowLayer extends Layer { this.state = { maxTextureSize, webgl2: isWebGL2(gl), - }; + } as this['state']; - this.getAttributeManager().addInstanced({ + this.getAttributeManager()?.addInstanced({ instanceSize: { size: 1, transition: true, @@ -170,13 +221,19 @@ export class ArrowLayer extends Layer { }); } - finalizeState() { - super.finalizeState(); + finalizeState(context: LayerContext) { + super.finalizeState(context); // we do not use setState to avoid a redraw, it is just used to stop the animation this.state.stop = true; } - createTexture2D(gl, data, elementSize, format, dataFormat) { + createTexture2D( + gl: WebGLRenderingContext, + data: Array, + elementSize: number, + format: number, // is it TextureFormat? + dataFormat: number // is it TextureFormat? + ) { const start = performance.now(); // we calculate the smallest square texture that is a power of 2 but less or equals to MAX_TEXTURE_SIZE @@ -221,11 +278,11 @@ export class ArrowLayer extends Layer { return texture2d; } - createTexturesStructure(props) { + createTexturesStructure(props: this['props']) { const start = performance.now(); - const linePositionsTextureData = []; - const lineDistancesTextureData = []; + const linePositionsTextureData: number[] = []; + const lineDistancesTextureData: number[] = []; const lineAttributes = new Map(); let lineDistance = 0; @@ -241,14 +298,16 @@ export class ArrowLayer extends Layer { const lineDistancesTextureOffset = lineDistancesTextureData.length; let linePointCount = 0; if (positions.length > 0) { - positions.forEach((position) => { + positions.forEach((position: Position) => { // fill line positions texture linePositionsTextureData.push(position[0]); linePositionsTextureData.push(position[1]); linePointCount++; }); - lineDistancesTextureData.push(...line.cumulativeDistances); - lineDistance = line.cumulativeDistances[line.cumulativeDistances.length - 1]; + if (line.cumulativeDistances) { + lineDistancesTextureData.push(...line.cumulativeDistances); + lineDistance = line.cumulativeDistances[line.cumulativeDistances.length - 1]; + } } if (linePointCount > MAX_LINE_POINT_COUNT) { throw new Error(`Too many line point count (${linePointCount}), maximum is ${MAX_LINE_POINT_COUNT}`); @@ -272,7 +331,7 @@ export class ArrowLayer extends Layer { }; } - updateGeometry({ props, changeFlags }) { + updateGeometry({ props, changeFlags }: UpdateParameters) { const geometryChanged = changeFlags.dataChanged || (changeFlags.updateTriggersChanged && @@ -306,12 +365,12 @@ export class ArrowLayer extends Layer { }); if (!changeFlags.dataChanged) { - this.getAttributeManager().invalidateAll(); + this.getAttributeManager()?.invalidateAll(); } } } - updateModel({ changeFlags }) { + updateModel({ changeFlags }: UpdateParameters) { if (changeFlags.extensionsChanged) { const { gl } = this.context; @@ -324,11 +383,11 @@ export class ArrowLayer extends Layer { model: this._getModel(gl), }); - this.getAttributeManager().invalidateAll(); + this.getAttributeManager()?.invalidateAll(); } } - updateState(updateParams) { + updateState(updateParams: UpdateParameters) { super.updateState(updateParams); this.updateGeometry(updateParams); @@ -347,7 +406,7 @@ export class ArrowLayer extends Layer { } } - animate(timestamp) { + animate(timestamp: number) { if (this.state.stop) { return; } @@ -361,30 +420,33 @@ export class ArrowLayer extends Layer { window.requestAnimationFrame((timestamp) => this.animate(timestamp)); } - draw({ uniforms }) { + // TODO find the full type for record values + draw({ uniforms }: { uniforms: Record> }) { const { sizeMinPixels, sizeMaxPixels } = this.props; const { linePositionsTexture, lineDistancesTexture, timestamp, webgl2 } = this.state; - this.state.model - .setUniforms(uniforms) - .setUniforms({ - sizeMinPixels, - sizeMaxPixels, - linePositionsTexture, - lineDistancesTexture, - linePositionsTextureSize: [linePositionsTexture.width, linePositionsTexture.height], - lineDistancesTextureSize: [lineDistancesTexture.width, lineDistancesTexture.height], - timestamp, - webgl2, - distanceBetweenLines: this.props.getDistanceBetweenLines, - maxParallelOffset: this.props.maxParallelOffset, - minParallelOffset: this.props.minParallelOffset, - }) - .draw(); + if (this.state.model) { + this.state.model + .setUniforms({ + ...uniforms, + sizeMinPixels, + sizeMaxPixels, + linePositionsTexture, + lineDistancesTexture, + linePositionsTextureSize: [linePositionsTexture.width, linePositionsTexture.height], + lineDistancesTextureSize: [lineDistancesTexture.width, lineDistancesTexture.height], + timestamp, + webgl2, + distanceBetweenLines: this.props.getDistanceBetweenLines, + maxParallelOffset: this.props.maxParallelOffset, + minParallelOffset: this.props.minParallelOffset, + }) + .draw(); + } } - _getModel(gl) { + _getModel(gl: WebGLRenderingContext) { const positions = [-1, -1, 0, 0, 1, 0, 0, -0.6, 0, 1, -1, 0, 0, 1, 0, 0, -0.6, 0]; return new Model( @@ -406,6 +468,3 @@ export class ArrowLayer extends Layer { ); } } - -ArrowLayer.layerName = 'ArrowLayer'; -ArrowLayer.defaultProps = defaultProps; diff --git a/src/components/network-map-viewer/network/layers/fork-line-layer.js b/src/components/network-map-viewer/network/layers/fork-line-layer.ts similarity index 73% rename from src/components/network-map-viewer/network/layers/fork-line-layer.js rename to src/components/network-map-viewer/network/layers/fork-line-layer.ts index ca8708e1..eaaee0a4 100644 --- a/src/components/network-map-viewer/network/layers/fork-line-layer.js +++ b/src/components/network-map-viewer/network/layers/fork-line-layer.ts @@ -5,18 +5,34 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { LineLayer } from 'deck.gl'; +import { Accessor, LineLayer, LineLayerProps } from 'deck.gl'; +import { DefaultProps } from '@deck.gl/core'; import GL from '@luma.gl/constants'; +import { UniformValues } from 'maplibre-gl'; -const defaultProps = { +export type ForkLineLayerProps = _ForkLineLayerProps & LineLayerProps; + +type _ForkLineLayerProps = { + getLineParallelIndex: Accessor; + getLineAngle: Accessor; + distanceBetweenLines: Accessor; + maxParallelOffset: Accessor; + minParallelOffset: Accessor; + substationRadius: Accessor; + substationMaxPixel: Accessor; + minSubstationRadiusPixel: Accessor; + getDistanceBetweenLines: Accessor; + getMaxParallelOffset: Accessor; + getMinParallelOffset: Accessor; + getSubstationRadius: Accessor; + getSubstationMaxPixel: Accessor; + getMinSubstationRadiusPixel: Accessor; +}; + +const defaultProps: DefaultProps = { getLineParallelIndex: { type: 'accessor', value: 0 }, getLineAngle: { type: 'accessor', value: 0 }, distanceBetweenLines: { type: 'number', value: 1000 }, - maxParallelOffset: { type: 'number', value: 100 }, - minParallelOffset: { type: 'number', value: 3 }, - substationRadius: { type: 'number', value: 500 }, - substationMaxPixel: { type: 'number', value: 5 }, - minSubstationRadiusPixel: { type: 'number', value: 1 }, }; /** @@ -32,7 +48,10 @@ const defaultProps = { * substationMaxPixel: max pixel for a voltage level in substation * minSubstationRadiusPixel : min pixel for a substation */ -export default class ForkLineLayer extends LineLayer { +export default class ForkLineLayer extends LineLayer>> { + static layerName = 'ForkLineLayer'; + static defaultProps = defaultProps; + getShaders() { const shaders = super.getShaders(); shaders.inject = { @@ -71,11 +90,11 @@ uniform float minSubstationRadiusPixel; return shaders; } - initializeState(params) { - super.initializeState(params); + initializeState() { + super.initializeState(); const attributeManager = this.getAttributeManager(); - attributeManager.addInstanced({ + attributeManager?.addInstanced({ instanceLineParallelIndex: { size: 1, type: GL.FLOAT, @@ -99,7 +118,8 @@ uniform float minSubstationRadiusPixel; }); } - draw({ uniforms }) { + // TODO find the full type for record values + draw({ uniforms }: { uniforms: Record> }) { super.draw({ uniforms: { ...uniforms, @@ -113,6 +133,3 @@ uniform float minSubstationRadiusPixel; }); } } - -ForkLineLayer.layerName = 'ForkLineLayer'; -ForkLineLayer.defaultProps = defaultProps; diff --git a/src/components/network-map-viewer/network/layers/parallel-path-layer.js b/src/components/network-map-viewer/network/layers/parallel-path-layer.ts similarity index 84% rename from src/components/network-map-viewer/network/layers/parallel-path-layer.js rename to src/components/network-map-viewer/network/layers/parallel-path-layer.ts index e100f378..d724d036 100644 --- a/src/components/network-map-viewer/network/layers/parallel-path-layer.js +++ b/src/components/network-map-viewer/network/layers/parallel-path-layer.ts @@ -4,16 +4,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { PathLayer } from 'deck.gl'; +import { Accessor, PathLayer, PathLayerProps } from 'deck.gl'; +import { DefaultProps } from '@deck.gl/core'; import GL from '@luma.gl/constants'; - -const defaultProps = { - getLineParallelIndex: { type: 'accessor', value: 0 }, - getLineAngle: { type: 'accessor', value: 0 }, - distanceBetweenLines: { type: 'number', value: 1000 }, - maxParallelOffset: { type: 'number', value: 100 }, - minParallelOffset: { type: 'number', value: 3 }, -}; +import { UniformValues } from 'maplibre-gl'; /** * A layer based on PathLayer allowing to shift path by an offset + angle @@ -27,7 +21,30 @@ const defaultProps = { * maxParallelOffset: max pixel distance * minParallelOffset: min pixel distance */ -export default class ParallelPathLayer extends PathLayer { + +type _ParallelPathLayerProps = { + getLineParallelIndex?: Accessor; + getLineAngle?: Accessor; + distanceBetweenLines?: number; + maxParallelOffset?: number; + minParallelOffset?: number; +}; + +export type ParallelPathLayerProps = _ParallelPathLayerProps & PathLayerProps; + +const defaultProps: DefaultProps = { + getLineParallelIndex: { type: 'accessor', value: 0 }, + getLineAngle: { type: 'accessor', value: 0 }, + distanceBetweenLines: { type: 'number', value: 1000 }, +}; + +export default class ParallelPathLayer extends PathLayer< + DataT, + Required> +> { + static layerName = 'ParallelPathLayer'; + static defaultProps = defaultProps; + getShaders() { const shaders = super.getShaders(); shaders.inject = Object.assign({}, shaders.inject, { @@ -101,11 +118,11 @@ gl_Position += project_common_position_to_clipspace(trans) - project_uCenter; return shaders; } - initializeState(params) { - super.initializeState(params); + initializeState() { + super.initializeState(); const attributeManager = this.getAttributeManager(); - attributeManager.addInstanced({ + attributeManager?.addInstanced({ // too much instances variables need to compact some... instanceExtraAttributes: { size: 4, @@ -115,7 +132,8 @@ gl_Position += project_common_position_to_clipspace(trans) - project_uCenter; }); } - draw({ uniforms }) { + // TODO find the full type for record values + draw({ uniforms }: { uniforms: Record> }) { super.draw({ uniforms: { ...uniforms, @@ -126,6 +144,3 @@ gl_Position += project_common_position_to_clipspace(trans) - project_uCenter; }); } } - -ParallelPathLayer.layerName = 'ParallelPathLayer'; -ParallelPathLayer.defaultProps = defaultProps; diff --git a/src/components/network-map-viewer/network/layers/scatterplot-layer-ext.js b/src/components/network-map-viewer/network/layers/scatterplot-layer-ext.ts similarity index 61% rename from src/components/network-map-viewer/network/layers/scatterplot-layer-ext.js rename to src/components/network-map-viewer/network/layers/scatterplot-layer-ext.ts index d7d8fc9d..e1ea5df3 100644 --- a/src/components/network-map-viewer/network/layers/scatterplot-layer-ext.js +++ b/src/components/network-map-viewer/network/layers/scatterplot-layer-ext.ts @@ -4,17 +4,29 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { ScatterplotLayer } from 'deck.gl'; +import { Accessor, ScatterplotLayer, ScatterplotLayerProps } from 'deck.gl'; +import { DefaultProps } from '@deck.gl/core'; + import GL from '@luma.gl/constants'; -const defaultProps = { +type _ScatterplotLayerExtProps = { + getRadiusMaxPixels: Accessor; +}; +export type ScatterplotLayerExtProps = _ScatterplotLayerExtProps & ScatterplotLayerProps; + +const defaultProps: DefaultProps = { getRadiusMaxPixels: { type: 'accessor', value: 1 }, }; /** * An extended scatter plot layer that allows a radius max pixels to be different for each object. */ -export default class ScatterplotLayerExt extends ScatterplotLayer { +export default class ScatterplotLayerExt extends ScatterplotLayer< + Required<_ScatterplotLayerExtProps> +> { + static layerName = 'ScatterplotLayerExt'; + static defaultProps = defaultProps; + getShaders() { const shaders = super.getShaders(); return Object.assign({}, shaders, { @@ -27,11 +39,11 @@ attribute float instanceRadiusMaxPixels; }); } - initializeState(params) { - super.initializeState(params); + initializeState() { + super.initializeState(); const attributeManager = this.getAttributeManager(); - attributeManager.addInstanced({ + attributeManager?.addInstanced({ instanceRadiusMaxPixels: { size: 1, transition: true, @@ -42,6 +54,3 @@ attribute float instanceRadiusMaxPixels; }); } } - -ScatterplotLayerExt.layerName = 'ScatterplotLayerExt'; -ScatterplotLayerExt.defaultProps = defaultProps; diff --git a/src/components/network-map-viewer/network/line-layer.js b/src/components/network-map-viewer/network/line-layer.ts similarity index 65% rename from src/components/network-map-viewer/network/line-layer.js rename to src/components/network-map-viewer/network/line-layer.ts index 56e497ad..36b1f3e2 100644 --- a/src/components/network-map-viewer/network/line-layer.js +++ b/src/components/network-map-viewer/network/line-layer.ts @@ -5,41 +5,54 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { CompositeLayer, TextLayer, IconLayer } from 'deck.gl'; +import { + CompositeLayer, + TextLayer, + IconLayer, + Position, + Color, + CompositeLayerProps, + LayerContext, + UpdateParameters, + Layer, +} from 'deck.gl'; import PadlockIcon from '../images/lock_black_24dp.svg?react'; import BoltIcon from '../images/bolt_black_24dp.svg?react'; import { PathStyleExtension } from '@deck.gl/extensions'; -import { ArrowLayer, ArrowDirection } from './layers/arrow-layer'; +import { ArrowLayer, ArrowDirection, Arrow } from './layers/arrow-layer'; import ParallelPathLayer from './layers/parallel-path-layer'; import ForkLineLayer from './layers/fork-line-layer'; import { getDistance } from 'geolib'; import { SUBSTATION_RADIUS, SUBSTATION_RADIUS_MAX_PIXEL, SUBSTATION_RADIUS_MIN_PIXEL } from './constants'; -import { INVALID_FLOW_OPACITY } from '../../../utils/colors'; +import { getNominalVoltageColor, INVALID_FLOW_OPACITY } from '../../../utils/colors'; +import { Line, LonLat, VoltageLevel } from '../utils/equipment-types'; +import { MapEquipments } from './map-equipments'; +import { GeoData } from './geo-data'; const DISTANCE_BETWEEN_ARROWS = 10000.0; //Constants for Feeders mode const START_ARROW_POSITION = 0.1; const END_ARROW_POSITION = 0.9; -export const LineFlowMode = { - STATIC_ARROWS: 'staticArrows', - ANIMATED_ARROWS: 'animatedArrows', - FEEDERS: 'feeders', -}; +export enum LineFlowMode { + STATIC_ARROWS = 'staticArrows', + ANIMATED_ARROWS = 'animatedArrows', + FEEDERS = 'feeders', +} -export const LineFlowColorMode = { - NOMINAL_VOLTAGE: 'nominalVoltage', - OVERLOADS: 'overloads', -}; +export enum LineFlowColorMode { + NOMINAL_VOLTAGE = 'nominalVoltage', + OVERLOADS = 'overloads', +} const noDashArray = [0, 0]; const dashArray = [15, 10]; -function doDash(lineConnection) { +function doDash(lineConnection: LineConnection) { return !lineConnection.terminal1Connected || !lineConnection.terminal2Connected; } -function getArrowDirection(p) { +function getArrowDirection(p: number) { if (p < 0) { return ArrowDirection.FROM_SIDE_2_TO_SIDE_1; } else if (p > 0) { @@ -49,18 +62,22 @@ function getArrowDirection(p) { } } -export const LineLoadingZone = { - UNKNOWN: 0, - SAFE: 1, - WARNING: 2, - OVERLOAD: 3, -}; +export enum LineLoadingZone { + UNKNOWN = 0, + SAFE = 1, + WARNING = 2, + OVERLOAD = 3, +} -export function getLineLoadingZoneOfSide(limit, intensity, lineFlowAlertThreshold) { +export function getLineLoadingZoneOfSide( + limit: number | undefined, + intensity: number | undefined, + lineFlowAlertThreshold: number +) { if (limit === undefined || intensity === undefined || intensity === 0) { return LineLoadingZone.UNKNOWN; } else { - let threshold = (lineFlowAlertThreshold * limit) / 100; + const threshold = (lineFlowAlertThreshold * limit) / 100; const absoluteIntensity = Math.abs(intensity); if (absoluteIntensity < threshold) { return LineLoadingZone.SAFE; @@ -72,13 +89,13 @@ export function getLineLoadingZoneOfSide(limit, intensity, lineFlowAlertThreshol } } -export function getLineLoadingZone(line, lineFlowAlertThreshold) { +export function getLineLoadingZone(line: Line, lineFlowAlertThreshold: number) { const zone1 = getLineLoadingZoneOfSide(line.currentLimits1?.permanentLimit, line.i1, lineFlowAlertThreshold); const zone2 = getLineLoadingZoneOfSide(line.currentLimits2?.permanentLimit, line.i2, lineFlowAlertThreshold); return Math.max(zone1, zone2); } -function getLineLoadingZoneColor(zone) { +function getLineLoadingZoneColor(zone: LineLoadingZone): Color { if (zone === LineLoadingZone.UNKNOWN) { return [128, 128, 128]; // grey } else if (zone === LineLoadingZone.SAFE) { @@ -92,7 +109,7 @@ function getLineLoadingZoneColor(zone) { } } -function getLineColor(line, nominalVoltageColor, props, lineConnection) { +function getLineColor(line: Line, nominalVoltageColor: Color, props: LineLayerProps, lineConnection: LineConnection) { if (props.lineFlowColorMode === LineFlowColorMode.NOMINAL_VOLTAGE) { if (!lineConnection.terminal1Connected && !lineConnection.terminal2Connected) { return props.disconnectedLineColor; @@ -107,7 +124,7 @@ function getLineColor(line, nominalVoltageColor, props, lineConnection) { } } -function getLineIcon(lineStatus) { +function getLineIcon(lineStatus: LineStatus) { return { url: lineStatus === 'PLANNED_OUTAGE' ? PadlockIcon : lineStatus === 'FORCED_OUTAGE' ? BoltIcon : undefined, height: 24, @@ -124,7 +141,7 @@ export const ArrowSpeed = { CRAZY: 4, }; -function getArrowSpeedOfSide(limit, intensity) { +function getArrowSpeedOfSide(limit: number | undefined, intensity: number | undefined) { if (limit === undefined || intensity === undefined || intensity === 0) { return ArrowSpeed.STOPPED; } else { @@ -141,13 +158,13 @@ function getArrowSpeedOfSide(limit, intensity) { } } -function getArrowSpeed(line) { +function getArrowSpeed(line: Line) { const speed1 = getArrowSpeedOfSide(line.currentLimits1?.permanentLimit, line.i1); const speed2 = getArrowSpeedOfSide(line.currentLimits2?.permanentLimit, line.i2); return Math.max(speed1, speed2); } -function getArrowSpeedFactor(speed) { +function getArrowSpeedFactor(speed: number) { switch (speed) { case ArrowSpeed.STOPPED: return 0; @@ -164,9 +181,125 @@ function getArrowSpeedFactor(speed) { } } -export class LineLayer extends CompositeLayer { - initializeState() { - super.initializeState(); +type LineConnection = { + terminal1Connected: boolean; + terminal2Connected: boolean; +}; + +export enum LineStatus { + PLANNED_OUTAGE = 'PLANNED_OUTAGE', + FORCED_OUTAGE = 'FORCED_OUTAGE', + IN_OPERATION = 'IN_OPERATION', +} + +type LinesStatus = { + operatingStatus: LineStatus; +}; + +type CompositeDataLine = { + nominalV: number; + lines: Line[]; + arrows: Arrow[]; + positions: LonLat[]; + cumulativeDistances: number[]; +}; + +type ActivePower = { + p: number | undefined; + printPosition: Position; + offset: [number, number]; + line: Line; +}; + +type OperatingStatus = { + status: LineStatus; + printPosition: Position; + offset: [number, number]; +}; + +export type CompositeData = { + nominalV: number; + mapOriginDestination?: Map>; + lines: Line[]; + lineMap?: Map; + activePower: ActivePower[]; + operatingStatus: OperatingStatus[]; + arrows: Arrow[]; +}; + +type MinProximityFactor = { + lines: Line[]; + start: number; + end: number; +}; + +type _LineLayerProps = { + data: Line[]; + network: MapEquipments; + geoData: GeoData; + getNominalVoltageColor: (voltage: number) => Color; + disconnectedLineColor: Color; + filteredNominalVoltages: number[] | null; + lineFlowMode: LineFlowMode; + lineFlowColorMode: LineFlowColorMode; + lineFlowAlertThreshold: number; + showLineFlow: boolean; + lineFullPath: boolean; + lineParallelPath: boolean; + labelSize: number; + iconSize: number; + distanceBetweenLines: number; + maxParallelOffset: number; + minParallelOffset: number; + substationRadius: number; + substationMaxPixel: number; + minSubstationRadiusPixel: number; + areFlowsValid: boolean; + updatedLines: Line[]; + labelsVisible: boolean; + labelColor: Color; +}; + +export type LineLayerProps = _LineLayerProps & CompositeLayerProps; + +const defaultProps = { + network: null, + geoData: null, + getNominalVoltageColor: { type: 'accessor', value: getNominalVoltageColor }, + disconnectedLineColor: { type: 'color', value: [255, 255, 255] }, + filteredNominalVoltages: null, + lineFlowMode: LineFlowMode.FEEDERS, + lineFlowColorMode: LineFlowColorMode.NOMINAL_VOLTAGE, + lineFlowAlertThreshold: 100, + showLineFlow: true, + lineFullPath: true, + lineParallelPath: true, + labelSize: 12, + iconSize: 48, + distanceBetweenLines: 1000, + maxParallelOffset: 100, + minParallelOffset: 3, + substationRadius: { type: 'number', value: SUBSTATION_RADIUS }, + substationMaxPixel: { type: 'number', value: SUBSTATION_RADIUS_MAX_PIXEL }, + minSubstationRadiusPixel: { + type: 'number', + value: SUBSTATION_RADIUS_MIN_PIXEL, + }, + labelColor: [255, 255, 255], +}; + +export class LineLayer extends CompositeLayer> { + static layerName = 'LineLayer'; + static defaultProps = defaultProps; + + declare state: { + compositeData: CompositeData[]; + linesConnection: Map; + linesStatus: Map; + }; + + initializeState(context: LayerContext) { + super.initializeState(context); this.state = { compositeData: [], @@ -175,17 +308,23 @@ export class LineLayer extends CompositeLayer { }; } - getVoltageLevelIndex(voltageLevelId) { + getVoltageLevelIndex(voltageLevelId: string) { const { network } = this.props; const vl = network.getVoltageLevel(voltageLevelId); + if (vl === undefined) { + return undefined; + } const substation = network.getSubstation(vl.substationId); + if (substation === undefined) { + return undefined; + } return ( [ ...new Set( - substation.voltageLevels.map((vl) => vl.nominalV) // only one voltage level + substation.voltageLevels.map((vl: VoltageLevel) => vl.nominalV) // only one voltage level ), ] - .sort((a, b) => { + .sort((a: number, b: number) => { return a - b; // force numerical sort }) .indexOf(vl.nominalV) + 1 @@ -193,16 +332,16 @@ export class LineLayer extends CompositeLayer { } //TODO this is a huge function, refactor - updateState({ props, oldProps, changeFlags }) { - let compositeData; - let linesConnection; - let linesStatus; + updateState({ props, oldProps, changeFlags }: UpdateParameters) { + let compositeData: Partial[]; + let linesConnection: Map | undefined; + let linesStatus: Map | undefined; if (changeFlags.dataChanged) { compositeData = []; - linesConnection = new Map(); - linesStatus = new Map(); + linesConnection = new Map(); + linesStatus = new Map(); if ( props.network != null && @@ -211,10 +350,10 @@ export class LineLayer extends CompositeLayer { props.geoData != null ) { // group lines by nominal voltage - const lineNominalVoltageIndexer = (map, line) => { + const lineNominalVoltageIndexer = (map: Map, line: Line) => { const network = props.network; - const vl1 = network.getVoltageLevel(line.voltageLevelId1); - const vl2 = network.getVoltageLevel(line.voltageLevelId2); + const vl1 = network.getVoltageLevel(line.voltageLevelId1)!; + const vl2 = network.getVoltageLevel(line.voltageLevelId2)!; const vl = vl1 || vl2; let list = map.get(vl.nominalV); if (!list) { @@ -226,30 +365,30 @@ export class LineLayer extends CompositeLayer { } return map; }; - const linesByNominalVoltage = props.data.reduce(lineNominalVoltageIndexer, new Map()); + const linesByNominalVoltage = props.data.reduce(lineNominalVoltageIndexer, new Map()); compositeData = Array.from(linesByNominalVoltage.entries()) - .map((e) => { - return { nominalV: e[0], lines: e[1] }; + .map(([nominalV, lines]) => { + return { nominalV, lines }; }) .sort((a, b) => b.nominalV - a.nominalV); - compositeData.forEach((compositeData) => { + compositeData.forEach((c) => { //find lines with same substations set - let mapOriginDestination = new Map(); - compositeData.mapOriginDestination = mapOriginDestination; - compositeData.lines.forEach((line) => { - linesConnection.set(line.id, { + const mapOriginDestination = new Map(); + c.mapOriginDestination = mapOriginDestination; + c.lines?.forEach((line) => { + linesConnection?.set(line.id, { terminal1Connected: line.terminal1Connected, terminal2Connected: line.terminal2Connected, }); - linesStatus.set(line.id, { - operatingStatus: line.operatingStatus, + linesStatus?.set(line.id, { + operatingStatus: line.operatingStatus!, }); const key = this.genLineKey(line); - let val = mapOriginDestination.get(key); + const val = mapOriginDestination.get(key); if (val == null) { mapOriginDestination.set(key, new Set([line])); } else { @@ -265,12 +404,12 @@ export class LineLayer extends CompositeLayer { if (props.updatedLines !== oldProps.updatedLines) { props.updatedLines.forEach((line1) => { - linesConnection.set(line1.id, { + linesConnection?.set(line1.id, { terminal1Connected: line1.terminal1Connected, terminal2Connected: line1.terminal2Connected, }); - linesStatus.set(line1.id, { - operatingStatus: line1.operatingStatus, + linesStatus?.set(line1.id, { + operatingStatus: line1.operatingStatus!, }); }); } @@ -283,7 +422,7 @@ export class LineLayer extends CompositeLayer { props.lineParallelPath !== oldProps.lineParallelPath || props.geoData !== oldProps.geoData)) ) { - this.recomputeParallelLinesIndex(compositeData, props); + this.recomputeParallelLinesIndex(compositeData as CompositeData[], props); } if ( @@ -291,9 +430,9 @@ export class LineLayer extends CompositeLayer { (changeFlags.propsChanged && (oldProps.lineFullPath !== props.lineFullPath || oldProps.geoData !== props.geoData)) ) { - compositeData.forEach((compositeData) => { - let lineMap = new Map(); - compositeData.lines.forEach((line) => { + compositeData.forEach((c) => { + const lineMap = new Map(); + c.lines?.forEach((line) => { const positions = props.geoData.getLinePositions(props.network, line, props.lineFullPath); const cumulativeDistances = props.geoData.getLineDistances(positions); lineMap.set(line.id, { @@ -302,7 +441,7 @@ export class LineLayer extends CompositeLayer { line: line, }); }); - compositeData.lineMap = lineMap; + c.lineMap = lineMap; }); } @@ -313,7 +452,7 @@ export class LineLayer extends CompositeLayer { props.lineParallelPath !== oldProps.lineParallelPath || props.geoData !== oldProps.geoData)) ) { - this.recomputeForkLines(compositeData, props); + this.recomputeForkLines(compositeData as CompositeData[], props); } if ( @@ -324,41 +463,45 @@ export class LineLayer extends CompositeLayer { props.geoData !== oldProps.geoData)) ) { //add labels - compositeData.forEach((compositeData) => { - compositeData.activePower = []; - compositeData.lines.forEach((line) => { - let lineData = compositeData.lineMap.get(line.id); - let arrowDirection = getArrowDirection(line.p1); - let coordinates1 = props.geoData.labelDisplayPosition( - lineData.positions, - lineData.cumulativeDistances, - START_ARROW_POSITION, - arrowDirection, - line.parallelIndex, - (line.angle * 180) / Math.PI, - (line.angleStart * 180) / Math.PI, - props.distanceBetweenLines, - line.proximityFactorStart - ); - let coordinates2 = props.geoData.labelDisplayPosition( - lineData.positions, - lineData.cumulativeDistances, - END_ARROW_POSITION, - arrowDirection, - line.parallelIndex, - (line.angle * 180) / Math.PI, - (line.angleEnd * 180) / Math.PI, - props.distanceBetweenLines, - line.proximityFactorEnd - ); + compositeData.forEach((cData) => { + cData.activePower = []; + cData.lines?.forEach((line) => { + const lineData = cData.lineMap?.get(line.id); + const arrowDirection = getArrowDirection(line.p1); + const coordinates1 = lineData + ? props.geoData.labelDisplayPosition( + lineData.positions, + lineData.cumulativeDistances, + START_ARROW_POSITION, + arrowDirection, + line.parallelIndex!, + (line.angle! * 180) / Math.PI, + (line.angleStart! * 180) / Math.PI, + props.distanceBetweenLines, + line.proximityFactorStart! + ) + : null; + const coordinates2 = lineData + ? props.geoData.labelDisplayPosition( + lineData.positions, + lineData.cumulativeDistances, + END_ARROW_POSITION, + arrowDirection, + line.parallelIndex!, + (line.angle! * 180) / Math.PI, + (line.angleEnd! * 180) / Math.PI, + props.distanceBetweenLines, + line.proximityFactorEnd! + ) + : null; if (coordinates1 !== null && coordinates2 !== null) { - compositeData.activePower.push({ + cData.activePower?.push({ line: line, p: line.p1, printPosition: [coordinates1.position.longitude, coordinates1.position.latitude], offset: coordinates1.offset, }); - compositeData.activePower.push({ + cData.activePower?.push({ line: line, p: line.p2, printPosition: [coordinates2.position.longitude, coordinates2.position.latitude], @@ -378,33 +521,40 @@ export class LineLayer extends CompositeLayer { props.geoData !== oldProps.geoData)) ) { //add icons - compositeData.forEach((compositeData) => { - compositeData.operatingStatus = []; - compositeData.lines.forEach((line) => { - let lineStatus = linesStatus.get(line.id); + compositeData.forEach((cData) => { + cData.operatingStatus = []; + cData.lines?.forEach((line) => { + const lineStatus = linesStatus?.get(line.id); if ( lineStatus !== undefined && lineStatus.operatingStatus !== undefined && lineStatus.operatingStatus !== 'IN_OPERATION' ) { - let lineData = compositeData.lineMap.get(line.id); - let coordinatesIcon = props.geoData.labelDisplayPosition( - lineData.positions, - lineData.cumulativeDistances, - 0.5, - ArrowDirection.NONE, - line.parallelIndex, - (line.angle * 180) / Math.PI, - (line.angleEnd * 180) / Math.PI, - props.distanceBetweenLines, - line.proximityFactorEnd - ); - if (coordinatesIcon !== null) { - compositeData.operatingStatus.push({ - status: lineStatus.operatingStatus, - printPosition: [coordinatesIcon.position.longitude, coordinatesIcon.position.latitude], - offset: coordinatesIcon.offset, - }); + if (cData.lineMap) { + const lineData = cData.lineMap.get(line.id); + if (lineData) { + const coordinatesIcon = props.geoData.labelDisplayPosition( + lineData.positions, + lineData.cumulativeDistances, + 0.5, + ArrowDirection.NONE, + line.parallelIndex!, + (line.angle! * 180) / Math.PI, + (line.angleEnd! * 180) / Math.PI, + props.distanceBetweenLines, + line.proximityFactorEnd! + ); + if (coordinatesIcon !== null) { + cData.operatingStatus?.push({ + status: lineStatus.operatingStatus, + printPosition: [ + coordinatesIcon.position.longitude, + coordinatesIcon.position.latitude, + ], + offset: coordinatesIcon.offset, + }); + } + } } } }); @@ -423,12 +573,12 @@ export class LineLayer extends CompositeLayer { oldProps.lineFlowMode === LineFlowMode.FEEDERS)))) ) { // add arrows - compositeData.forEach((compositeData) => { - const lineMap = compositeData.lineMap; + compositeData.forEach((cData) => { + const lineMap = cData.lineMap!; // create one arrow each DISTANCE_BETWEEN_ARROWS - compositeData.arrows = compositeData.lines.flatMap((line) => { - let lineData = lineMap.get(line.id); + cData.arrows = cData.lines?.flatMap((line) => { + const lineData = lineMap.get(line.id)!; line.cumulativeDistances = lineData.cumulativeDistances; line.positions = lineData.positions; @@ -470,22 +620,18 @@ export class LineLayer extends CompositeLayer { }); }); } - this.setState({ - compositeData: compositeData, - linesConnection: linesConnection, - linesStatus: linesStatus, - }); + this.setState({ compositeData, linesConnection, linesStatus }); } - genLineKey(line) { + genLineKey(line: Line) { return line.voltageLevelId1 > line.voltageLevelId2 ? line.voltageLevelId1 + '##' + line.voltageLevelId2 : line.voltageLevelId2 + '##' + line.voltageLevelId1; } - recomputeParallelLinesIndex(compositeData, props) { - compositeData.forEach((compositeData) => { - const mapOriginDestination = compositeData.mapOriginDestination; + recomputeParallelLinesIndex(compositeData: CompositeData[], props: this['props']) { + compositeData.forEach((cData) => { + const mapOriginDestination = cData.mapOriginDestination!; // calculate index for line with same substation set // The index is a real number in a normalized unit. // +1 => distanceBetweenLines on side @@ -516,11 +662,14 @@ export class LineLayer extends CompositeLayer { }); } - recomputeForkLines(compositeData, props) { - const mapMinProximityFactor = new Map(); - compositeData.forEach((compositeData) => { - compositeData.lines.forEach((line) => { - const positions = compositeData.lineMap.get(line.id).positions; + recomputeForkLines(compositeData: CompositeData[], props: this['props']) { + const mapMinProximityFactor = new Map(); + compositeData.forEach((cData) => { + cData.lines.forEach((line) => { + const positions = cData?.lineMap?.get(line.id)?.positions; + if (!positions) { + return; + } //the first and last in positions doesn't depend on lineFullPath line.origin = positions[0]; line.end = positions[positions.length - 1]; @@ -541,8 +690,8 @@ export class LineLayer extends CompositeLayer { positions[positions.length - 1] ); - let key = this.genLineKey(line); - let val = mapMinProximityFactor.get(key); + const key = this.genLineKey(line); + const val = mapMinProximityFactor.get(key); if (val == null) { mapMinProximityFactor.set(key, { lines: [line], @@ -565,7 +714,7 @@ export class LineLayer extends CompositeLayer { ); } - getProximityFactor(firstPosition, secondPosition) { + getProximityFactor(firstPosition: LonLat, secondPosition: LonLat) { let factor = getDistance(firstPosition, secondPosition) / (3 * this.props.distanceBetweenLines); if (factor > 1) { factor = 1; @@ -573,7 +722,7 @@ export class LineLayer extends CompositeLayer { return factor; } - computeAngle(props, position1, position2) { + computeAngle(props: this['props'], position1: LonLat, position2: LonLat) { let angle = props.geoData.getMapAngle(position1, position2); angle = (angle * Math.PI) / 180 + Math.PI; if (angle < 0) { @@ -583,7 +732,7 @@ export class LineLayer extends CompositeLayer { } renderLayers() { - const layers = []; + const layers: Layer[] = []; const linePathUpdateTriggers = [ this.props.lineFullPath, @@ -592,36 +741,36 @@ export class LineLayer extends CompositeLayer { ]; // lines : create one layer per nominal voltage, starting from higher to lower nominal voltage - this.state.compositeData.forEach((compositeData) => { - const nominalVoltageColor = this.props.getNominalVoltageColor(compositeData.nominalV); + this.state.compositeData.forEach((cData) => { + const nominalVoltageColor = this.props.getNominalVoltageColor(cData.nominalV); const lineLayer = new ParallelPathLayer( this.getSubLayerProps({ - id: 'LineNominalVoltage' + compositeData.nominalV, - data: compositeData.lines, + id: 'LineNominalVoltage' + cData.nominalV, + data: cData.lines, widthScale: 20, widthMinPixels: 1, widthMaxPixels: 2, - getPath: (line) => + getPath: (line: Line) => this.props.geoData.getLinePositions(this.props.network, line, this.props.lineFullPath), - getColor: (line) => - getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)), + getColor: (line: Line) => + getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)!), getWidth: 2, - getLineParallelIndex: (line) => line.parallelIndex, - getExtraAttributes: (line) => [ + getLineParallelIndex: (line: Line) => line.parallelIndex, + getExtraAttributes: (line: Line) => [ line.angleStart, line.angle, line.angleEnd, - line.parallelIndex * 2 + + line.parallelIndex! * 2 + 31 + - 64 * (Math.ceil(line.proximityFactorStart * 512) - 1) + - 64 * 512 * (Math.ceil(line.proximityFactorEnd * 512) - 1), + 64 * (Math.ceil(line.proximityFactorStart! * 512) - 1) + + 64 * 512 * (Math.ceil(line.proximityFactorEnd! * 512) - 1), ], distanceBetweenLines: this.props.distanceBetweenLines, maxParallelOffset: this.props.maxParallelOffset, minParallelOffset: this.props.minParallelOffset, visible: !this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV), + this.props.filteredNominalVoltages.includes(cData.nominalV), updateTriggers: { getPath: linePathUpdateTriggers, getExtraAttributes: [this.props.lineParallelPath, linePathUpdateTriggers], @@ -633,7 +782,8 @@ export class LineLayer extends CompositeLayer { ], getDashArray: [this.props.updatedLines], }, - getDashArray: (line) => (doDash(this.state.linesConnection.get(line.id)) ? dashArray : noDashArray), + getDashArray: (line: Line) => + doDash(this.state.linesConnection!.get(line.id)!) ? dashArray : noDashArray, extensions: [new PathStyleExtension({ dash: true })], }) ); @@ -641,37 +791,40 @@ export class LineLayer extends CompositeLayer { const arrowLayer = new ArrowLayer( this.getSubLayerProps({ - id: 'ArrowNominalVoltage' + compositeData.nominalV, - data: compositeData.arrows, + id: 'ArrowNominalVoltage' + cData.nominalV, + data: cData.arrows, sizeMinPixels: 3, sizeMaxPixels: 7, - getDistance: (arrow) => arrow.distance, - getLine: (arrow) => arrow.line, - getLinePositions: (line) => + getDistance: (arrow: Arrow) => arrow.distance, + getLine: (arrow: Arrow) => arrow.line, + getLinePositions: (line: Line) => this.props.geoData.getLinePositions(this.props.network, line, this.props.lineFullPath), - getColor: (arrow) => + getColor: (arrow: Arrow) => getLineColor( arrow.line, nominalVoltageColor, this.props, - this.state.linesConnection.get(arrow.line.id) + this.state.linesConnection.get(arrow.line.id)! ), getSize: 700, - getSpeedFactor: (arrow) => getArrowSpeedFactor(getArrowSpeed(arrow.line)), - getLineParallelIndex: (arrow) => arrow.line.parallelIndex, - getLineAngles: (arrow) => [arrow.line.angleStart, arrow.line.angle, arrow.line.angleEnd], - getProximityFactors: (arrow) => [arrow.line.proximityFactorStart, arrow.line.proximityFactorEnd], + getSpeedFactor: (arrow: Arrow) => getArrowSpeedFactor(getArrowSpeed(arrow.line)), + getLineParallelIndex: (arrow: Arrow) => arrow.line.parallelIndex, + getLineAngles: (arrow: Arrow) => [arrow.line.angleStart, arrow.line.angle, arrow.line.angleEnd], + getProximityFactors: (arrow: Arrow) => [ + arrow.line.proximityFactorStart, + arrow.line.proximityFactorEnd, + ], getDistanceBetweenLines: this.props.distanceBetweenLines, maxParallelOffset: this.props.maxParallelOffset, minParallelOffset: this.props.minParallelOffset, - getDirection: (arrow) => { + getDirection: (arrow: Arrow) => { return getArrowDirection(arrow.line.p1); }, animated: this.props.showLineFlow && this.props.lineFlowMode === LineFlowMode.ANIMATED_ARROWS, visible: this.props.showLineFlow && (!this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV)), + this.props.filteredNominalVoltages.includes(cData.nominalV)), opacity: this.props.areFlowsValid ? 1 : INVALID_FLOW_OPACITY, updateTriggers: { getLinePositions: linePathUpdateTriggers, @@ -691,20 +844,20 @@ export class LineLayer extends CompositeLayer { const startFork = new ForkLineLayer( this.getSubLayerProps({ - id: 'LineForkStart' + compositeData.nominalV, - getSourcePosition: (line) => line.origin, - getTargetPosition: (line) => line.end, - getSubstationOffset: (line) => line.substationIndexStart, - data: compositeData.lines, + id: 'LineForkStart' + cData.nominalV, + getSourcePosition: (line: Line) => line.origin, + getTargetPosition: (line: Line) => line.end, + getSubstationOffset: (line: Line) => line.substationIndexStart, + data: cData.lines, widthScale: 20, widthMinPixels: 1, widthMaxPixels: 2, - getColor: (line) => - getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)), + getColor: (line: Line) => + getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)!), getWidth: 2, - getProximityFactor: (line) => line.proximityFactorStart, - getLineParallelIndex: (line) => line.parallelIndex, - getLineAngle: (line) => line.angleStart, + getProximityFactor: (line: Line) => line.proximityFactorStart, + getLineParallelIndex: (line: Line) => line.parallelIndex, + getLineAngle: (line: Line) => line.angleStart, getDistanceBetweenLines: this.props.distanceBetweenLines, getMaxParallelOffset: this.props.maxParallelOffset, getMinParallelOffset: this.props.minParallelOffset, @@ -713,7 +866,7 @@ export class LineLayer extends CompositeLayer { getMinSubstationRadiusPixel: this.props.minSubstationRadiusPixel, visible: !this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV), + this.props.filteredNominalVoltages.includes(cData.nominalV), updateTriggers: { getLineParallelIndex: linePathUpdateTriggers, getSourcePosition: linePathUpdateTriggers, @@ -733,20 +886,20 @@ export class LineLayer extends CompositeLayer { const endFork = new ForkLineLayer( this.getSubLayerProps({ - id: 'LineForkEnd' + compositeData.nominalV, - getSourcePosition: (line) => line.end, - getTargetPosition: (line) => line.origin, - getSubstationOffset: (line) => line.substationIndexEnd, - data: compositeData.lines, + id: 'LineForkEnd' + cData.nominalV, + getSourcePosition: (line: Line) => line.end, + getTargetPosition: (line: Line) => line.origin, + getSubstationOffset: (line: Line) => line.substationIndexEnd, + data: cData.lines, widthScale: 20, widthMinPixels: 1, widthMaxPixels: 2, - getColor: (line) => - getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)), + getColor: (line: Line) => + getLineColor(line, nominalVoltageColor, this.props, this.state.linesConnection.get(line.id)!), getWidth: 2, - getProximityFactor: (line) => line.proximityFactorEnd, - getLineParallelIndex: (line) => -line.parallelIndex, - getLineAngle: (line) => line.angleEnd + Math.PI, + getProximityFactor: (line: Line) => line.proximityFactorEnd, + getLineParallelIndex: (line: Line) => -line.parallelIndex!, + getLineAngle: (line: Line) => line.angleEnd! + Math.PI, getDistanceBetweenLines: this.props.distanceBetweenLines, getMaxParallelOffset: this.props.maxParallelOffset, getMinParallelOffset: this.props.minParallelOffset, @@ -755,7 +908,7 @@ export class LineLayer extends CompositeLayer { getMinSubstationRadiusPixel: this.props.minSubstationRadiusPixel, visible: !this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV), + this.props.filteredNominalVoltages.includes(cData.nominalV), updateTriggers: { getLineParallelIndex: [this.props.lineParallelPath], getSourcePosition: linePathUpdateTriggers, @@ -776,23 +929,24 @@ export class LineLayer extends CompositeLayer { // lines active power const lineActivePowerLabelsLayer = new TextLayer( this.getSubLayerProps({ - id: 'ActivePower' + compositeData.nominalV, - data: compositeData.activePower, - getText: (activePower) => (activePower.p !== undefined ? Math.round(activePower.p).toString() : ''), + id: 'ActivePower' + cData.nominalV, + data: cData.activePower, + getText: (activePower: ActivePower) => + activePower.p !== undefined ? Math.round(activePower.p).toString() : '', // The position passed to this layer causes a bug when zooming and maxParallelOffset is reached: // the label is not correctly positioned on the lines, they are slightly off. // In the custom layers, we clamp the distanceBetweenLines. This is not done in the deck.gl TextLayer // and IconLayer or in the position calculated here. - getPosition: (activePower) => activePower.printPosition, + getPosition: (activePower: ActivePower) => activePower.printPosition, getColor: this.props.labelColor, fontFamily: 'Roboto', getSize: this.props.labelSize, getAngle: 0, - getPixelOffset: (activePower) => activePower.offset.map((x) => x), + getPixelOffset: (activePower: ActivePower) => activePower.offset.map((x) => x), getTextAnchor: 'middle', visible: (!this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV)) && + this.props.filteredNominalVoltages.includes(cData.nominalV)) && this.props.labelsVisible, opacity: this.props.areFlowsValid ? 1 : INVALID_FLOW_OPACITY, updateTriggers: { @@ -807,20 +961,20 @@ export class LineLayer extends CompositeLayer { // line status const lineStatusIconLayer = new IconLayer( this.getSubLayerProps({ - id: 'OperatingStatus' + compositeData.nominalV, - data: compositeData.operatingStatus, + id: 'OperatingStatus' + cData.nominalV, + data: cData.operatingStatus, // The position passed to this layer causes a bug when zooming and maxParallelOffset is reached: // the icon is not correctly positioned on the lines, they are slightly off. // In the custom layers, we clamp the distanceBetweenLines. This is not done in the deck.gl TextLayer // and IconLayer or in the position calculated here. - getPosition: (operatingStatus) => operatingStatus.printPosition, - getIcon: (operatingStatus) => getLineIcon(operatingStatus.status), + getPosition: (operatingStatus: OperatingStatus) => operatingStatus.printPosition, + getIcon: (operatingStatus: OperatingStatus) => getLineIcon(operatingStatus.status), getSize: this.props.iconSize, getColor: () => this.props.labelColor, - getPixelOffset: (operatingStatus) => operatingStatus.offset, + getPixelOffset: (operatingStatus: OperatingStatus) => operatingStatus.offset, visible: (!this.props.filteredNominalVoltages || - this.props.filteredNominalVoltages.includes(compositeData.nominalV)) && + this.props.filteredNominalVoltages.includes(cData.nominalV)) && this.props.labelsVisible, updateTriggers: { getPosition: [this.props.lineParallelPath, linePathUpdateTriggers], @@ -836,30 +990,3 @@ export class LineLayer extends CompositeLayer { return layers; } } - -LineLayer.layerName = 'LineLayer'; - -LineLayer.defaultProps = { - network: null, - geoData: null, - getNominalVoltageColor: { type: 'accessor', value: [255, 255, 255] }, - disconnectedLineColor: { type: 'color', value: [255, 255, 255] }, - filteredNominalVoltages: null, - lineFlowMode: LineFlowMode.FEEDERS, - lineFlowColorMode: LineFlowColorMode.NOMINAL_VOLTAGE, - lineFlowAlertThreshold: 100, - showLineFlow: true, - lineFullPath: true, - lineParallelPath: true, - labelSize: 12, - iconSize: 48, - distanceBetweenLines: 1000, - maxParallelOffset: 100, - minParallelOffset: 3, - substationRadius: { type: 'number', value: SUBSTATION_RADIUS }, - substationMaxPixel: { type: 'number', value: SUBSTATION_RADIUS_MAX_PIXEL }, - minSubstationRadiusPixel: { - type: 'number', - value: SUBSTATION_RADIUS_MIN_PIXEL, - }, -}; diff --git a/src/components/network-map-viewer/network/map-equipments.js b/src/components/network-map-viewer/network/map-equipments.ts similarity index 73% rename from src/components/network-map-viewer/network/map-equipments.js rename to src/components/network-map-viewer/network/map-equipments.ts index 4f0e4a7e..fd7ed405 100644 --- a/src/components/network-map-viewer/network/map-equipments.js +++ b/src/components/network-map-viewer/network/map-equipments.ts @@ -5,35 +5,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { EQUIPMENT_TYPES } from '../utils/equipment-types'; +import { Equipment, EQUIPMENT_TYPES, Line, Substation, VoltageLevel } from '../utils/equipment-types'; -const elementIdIndexer = (map, element) => { +const elementIdIndexer = (map: Map, element: T) => { map.set(element.id, element); return map; }; export class MapEquipments { - substations = []; - - substationsById = new Map(); - - lines = []; - - linesById = new Map(); - - tieLines = []; - - tieLinesById = new Map(); - - hvdcLines = []; - - hvdcLinesById = new Map(); - - voltageLevels = []; - - voltageLevelsById = new Map(); - - nominalVoltages = []; + substations: Substation[] = []; + substationsById = new Map(); + lines: Line[] = []; + linesById = new Map(); + tieLines: Line[] = []; + tieLinesById = new Map(); + hvdcLines: Line[] = []; + hvdcLinesById = new Map(); + voltageLevels: VoltageLevel[] = []; + voltageLevelsById = new Map(); + nominalVoltages: number[] = []; intlRef = undefined; @@ -41,22 +31,22 @@ export class MapEquipments { // dummy constructor, to make children classes constructors happy } - newMapEquipmentForUpdate() { + newMapEquipmentForUpdate(): MapEquipments { /* shallow clone of the map-equipment https://stackoverflow.com/a/44782052 */ return Object.assign(Object.create(Object.getPrototypeOf(this)), this); } - checkAndGetValues(equipments) { + checkAndGetValues(equipments: Equipment[]) { return equipments ? equipments : []; } - completeSubstationsInfos(equipementsToIndex) { + completeSubstationsInfos(equipementsToIndex: Substation[]) { const nominalVoltagesSet = new Set(this.nominalVoltages); if (equipementsToIndex?.length === 0) { this.substationsById = new Map(); this.voltageLevelsById = new Map(); } - const substations = equipementsToIndex?.length > 0 ? equipementsToIndex : this.substations; + const substations: Substation[] = equipementsToIndex?.length > 0 ? equipementsToIndex : this.substations; substations.forEach((substation) => { // sort voltage levels inside substations by nominal voltage @@ -78,7 +68,7 @@ export class MapEquipments { this.nominalVoltages = Array.from(nominalVoltagesSet).sort((a, b) => b - a); } - updateEquipments(currentEquipments, newEquipements) { + updateEquipments(currentEquipments: T[], newEquipements: T[]) { // replace current modified equipments currentEquipments.forEach((equipment1, index) => { const found = newEquipements.filter((equipment2) => equipment2.id === equipment1.id); @@ -95,7 +85,7 @@ export class MapEquipments { return [...currentEquipments, ...eqptsToAdd]; } - updateSubstations(substations, fullReload) { + updateSubstations(substations: Substation[], fullReload: boolean) { if (fullReload) { this.substations = []; } @@ -122,7 +112,7 @@ export class MapEquipments { } }); - if (substationAdded === true || voltageLevelAdded === true) { + if (substationAdded || voltageLevelAdded) { this.substations = [...this.substations]; } @@ -130,7 +120,7 @@ export class MapEquipments { this.completeSubstationsInfos(fullReload ? [] : substations); } - completeLinesInfos(equipementsToIndex) { + completeLinesInfos(equipementsToIndex: Line[]) { if (equipementsToIndex?.length > 0) { equipementsToIndex.forEach((line) => { this.linesById?.set(line.id, line); @@ -140,7 +130,7 @@ export class MapEquipments { } } - completeTieLinesInfos(equipementsToIndex) { + completeTieLinesInfos(equipementsToIndex: Line[]) { if (equipementsToIndex?.length > 0) { equipementsToIndex.forEach((tieLine) => { this.tieLinesById?.set(tieLine.id, tieLine); @@ -150,31 +140,31 @@ export class MapEquipments { } } - updateLines(lines, fullReload) { + updateLines(lines: Line[], fullReload: boolean) { if (fullReload) { this.lines = []; } - this.lines = this.updateEquipments(this.lines, lines, EQUIPMENT_TYPES.LINE); + this.lines = this.updateEquipments(this.lines, lines); this.completeLinesInfos(fullReload ? [] : lines); } - updateTieLines(tieLines, fullReload) { + updateTieLines(tieLines: Line[], fullReload: boolean) { if (fullReload) { this.tieLines = []; } - this.tieLines = this.updateEquipments(this.tieLines, tieLines, EQUIPMENT_TYPES.TIE_LINE); + this.tieLines = this.updateEquipments(this.tieLines, tieLines); this.completeTieLinesInfos(fullReload ? [] : tieLines); } - updateHvdcLines(hvdcLines, fullReload) { + updateHvdcLines(hvdcLines: Line[], fullReload: boolean) { if (fullReload) { this.hvdcLines = []; } - this.hvdcLines = this.updateEquipments(this.hvdcLines, hvdcLines, EQUIPMENT_TYPES.HVDC_LINE); + this.hvdcLines = this.updateEquipments(this.hvdcLines, hvdcLines); this.completeHvdcLinesInfos(fullReload ? [] : hvdcLines); } - completeHvdcLinesInfos(equipementsToIndex) { + completeHvdcLinesInfos(equipementsToIndex: Line[]) { if (equipementsToIndex?.length > 0) { equipementsToIndex.forEach((hvdcLine) => { this.hvdcLinesById?.set(hvdcLine.id, hvdcLine); @@ -184,16 +174,16 @@ export class MapEquipments { } } - removeBranchesOfVoltageLevel(branchesList, voltageLevelId) { + removeBranchesOfVoltageLevel(branchesList: Line[], voltageLevelId: string) { const remainingLines = branchesList.filter( (l) => l.voltageLevelId1 !== voltageLevelId && l.voltageLevelId2 !== voltageLevelId ); - branchesList.filter((l) => !remainingLines.includes(l)).map((l) => this.linesById.delete(l.id)); + branchesList.filter((l) => !remainingLines.includes(l)).forEach((l) => this.linesById.delete(l.id)); return remainingLines; } - removeEquipment(equipmentType, equipmentId) { + removeEquipment(equipmentType: EQUIPMENT_TYPES, equipmentId: string) { switch (equipmentType) { case EQUIPMENT_TYPES.LINE: { this.lines = this.lines.filter((l) => l.id !== equipmentId); @@ -201,11 +191,15 @@ export class MapEquipments { break; } case EQUIPMENT_TYPES.VOLTAGE_LEVEL: { - const substationId = this.voltageLevelsById.get(equipmentId).substationId; - let voltageLevelsOfSubstation = this.substationsById.get(substationId).voltageLevels; - voltageLevelsOfSubstation = voltageLevelsOfSubstation.filter((l) => l.id !== equipmentId); - this.substationsById.get(substationId).voltageLevels = voltageLevelsOfSubstation; - + const substationId = this.voltageLevelsById.get(equipmentId)?.substationId; + if (substationId === undefined) { + return; + } + const substation = this.substationsById.get(substationId); + if (substation == null) { + return; + } + substation.voltageLevels = substation.voltageLevels.filter((l) => l.id !== equipmentId); this.removeBranchesOfVoltageLevel(this.lines, equipmentId); //New reference on substations to trigger reload of NetworkExplorer and NetworkMap this.substations = [...this.substations]; @@ -215,7 +209,10 @@ export class MapEquipments { this.substations = this.substations.filter((l) => l.id !== equipmentId); const substation = this.substationsById.get(equipmentId); - substation.voltageLevels.map((vl) => this.removeEquipment(EQUIPMENT_TYPES.VOLTAGE_LEVEL, vl.id)); + if (substation === undefined) { + return; + } + substation.voltageLevels.forEach((vl) => this.removeEquipment(EQUIPMENT_TYPES.VOLTAGE_LEVEL, vl.id)); this.completeSubstationsInfos([substation]); break; } @@ -227,7 +224,7 @@ export class MapEquipments { return this.voltageLevels; } - getVoltageLevel(id) { + getVoltageLevel(id: string) { return this.voltageLevelsById.get(id); } @@ -235,7 +232,7 @@ export class MapEquipments { return this.substations; } - getSubstation(id) { + getSubstation(id: string) { return this.substationsById.get(id); } @@ -247,7 +244,7 @@ export class MapEquipments { return this.lines; } - getLine(id) { + getLine(id: string) { return this.linesById.get(id); } @@ -255,7 +252,7 @@ export class MapEquipments { return this.hvdcLines; } - getHvdcLine(id) { + getHvdcLine(id: string) { return this.hvdcLinesById.get(id); } @@ -263,7 +260,7 @@ export class MapEquipments { return this.tieLines; } - getTieLine(id) { + getTieLine(id: string) { return this.tieLinesById.get(id); } } diff --git a/src/components/network-map-viewer/network/network-map.jsx b/src/components/network-map-viewer/network/network-map.jsx deleted file mode 100644 index fd1db690..00000000 --- a/src/components/network-map-viewer/network/network-map.jsx +++ /dev/null @@ -1,752 +0,0 @@ -/** - * Copyright (c) 2020, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * 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 { Box, decomposeColor } from '@mui/system'; -import { MapboxOverlay } from '@deck.gl/mapbox'; -import { Replay } from '@mui/icons-material'; -import { Button, useTheme } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; -import { Map, NavigationControl, useControl } 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 { MapEquipments } from './map-equipments'; -import { SubstationLayer } from './substation-layer'; -import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; -import LoaderWithOverlay from '../utils/loader-with-overlay'; -import mapboxgl from 'mapbox-gl'; -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'; - -// MouseEvent.button https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button -const MOUSE_EVENT_BUTTON_LEFT = 0; -const MOUSE_EVENT_BUTTON_RIGHT = 2; - -/** - * Represents the draw event types for the network map. - * 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, -}; - -// 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 -const DeckGLOverlay = forwardRef((props, ref) => { - const overlay = useControl(() => new MapboxOverlay(props)); - overlay.setProps(props); - useImperativeHandle(ref, () => overlay, [overlay]); - return null; -}); - -const PICKING_RADIUS = 5; - -const CARTO = 'carto'; -const CARTO_NOLABEL = 'cartonolabel'; -const MAPBOX = 'mapbox'; - -const LIGHT = 'light'; -const DARK = 'dark'; - -const styles = { - mapManualRefreshBackdrop: { - width: '100%', - height: '100%', - textAlign: 'center', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - background: 'grey', - opacity: '0.8', - zIndex: 99, - fontSize: 30, - }, -}; - -const FALLBACK_MAPBOX_TOKEN = - 'pk.eyJ1IjoiZ2VvZmphbWciLCJhIjoiY2pwbnRwcm8wMDYzMDQ4b2pieXd0bDMxNSJ9.Q4aL20nBo5CzGkrWtxroug'; - -const SUBSTATION_LAYER_PREFIX = 'substationLayer'; -const LINE_LAYER_PREFIX = 'lineLayer'; -const LABEL_SIZE = 12; -const INITIAL_CENTERED = { - lastCenteredSubstation: null, - centeredSubstationId: null, - centered: false, -}; -const DEFAULT_LOCATE_SUBSTATION_ZOOM_LEVEL = 12; - -// get polygon coordinates (features) or an empty object -function getPolygonFeatures() { - 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, - }); - } - } - } - } - - 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); - } - 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 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 - ) { - 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; - } else { - idVl = info.object.voltageLevels[0].id; - } - } 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 (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 && - 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) { - 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; - } - - 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: 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, - }); - } else { - setCursorType('grab'); - setTooltip(null); - } - }, - }) - ); - } - - 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) { - 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 - ); - }); - }); - }, [props.mapEquipments, mapEquipmentsLines, props.geoData, props.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)); - }) ?? [] - ); - }, [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: () => {}, -}; - -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, -}; - -export default memo(NetworkMap); - -function getSubstationsInPolygon(features, mapEquipments, geoData) { - const polygonCoordinates = features?.geometry; - if (!polygonCoordinates || polygonCoordinates.coordinates < 3) { - return []; - } - //get the list of substation - const substationsList = mapEquipments?.substations ?? []; - //for each substation, check if it is in the polygon - return substationsList // keep only the sybstation in the polygon - .filter((substation) => { - const pos = geoData.getSubstationPosition(substation.id); - return booleanPointInPolygon(pos, polygonCoordinates); - }); -} - -function getSelectedLinesInPolygon(network, lines, geoData, polygonCoordinates) { - return lines.filter((line) => { - try { - const linePos = geoData.getLinePositions(network, line); - if (!linePos) { - return false; - } - if (linePos.length < 2) { - return false; - } - const extremities = [linePos[0], linePos[linePos.length - 1]]; - return extremities.some((pos) => booleanPointInPolygon(pos, polygonCoordinates)); - } catch (error) { - console.error(error); - return false; - } - }); -} diff --git a/src/components/network-map-viewer/network/network-map.tsx b/src/components/network-map-viewer/network/network-map.tsx new file mode 100644 index 00000000..de5f981d --- /dev/null +++ b/src/components/network-map-viewer/network/network-map.tsx @@ -0,0 +1,838 @@ +/** + * Copyright (c) 2020, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +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, ButtonProps, useTheme } from '@mui/material'; +import { FormattedMessage } from 'react-intl'; +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, { 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'; +import LoaderWithOverlay from '../utils/loader-with-overlay'; +import mapboxgl from 'mapbox-gl'; +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 { 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; +const MOUSE_EVENT_BUTTON_RIGHT = 2; + +/** + * Represents the draw event types for the network map. + * 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 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 +const DeckGLOverlay = forwardRef((props, ref) => { + const overlay = useControl(() => new MapboxOverlay(props)); + overlay.setProps(props); + useImperativeHandle(ref, () => overlay, [overlay]); + 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: { + width: '100%', + height: '100%', + textAlign: 'center', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'grey', + opacity: '0.8', + zIndex: 99, + fontSize: 30, + }, +}; + +const FALLBACK_MAPBOX_TOKEN = + 'pk.eyJ1IjoiZ2VvZmphbWciLCJhIjoiY2pwbnRwcm8wMDYzMDQ4b2pieXd0bDMxNSJ9.Q4aL20nBo5CzGkrWtxroug'; + +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(): Feature | Record { + return getMapDrawer()?.getAll()?.features[0] ?? {}; +} + +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: 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); + } + } + + 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: PickingInfo, + event: mapboxgl.MapLayerMouseEvent | maplibregl.MapLayerMouseEvent, + network: MapEquipments + ) { + 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 { + idSubstation = info.object.voltageLevels[0].substationId; + } + } + 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 ( + 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); + } + } + } + + 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 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, + }) + ); + } + + 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 = () => ( + + ); + + 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 { + return 'mapbox://styles/mapbox/dark-v9'; + } + 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'; + } + }; + + 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 + ); + }); + }); + }, [mapEquipments, mapEquipmentsLines, geoData, filteredNominalVoltages]); + + 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] + ); + + const onDelete = useCallback(() => { + onPolygonChanged(getPolygonFeatures()); + onDrawEvent(DRAW_EVENT.DELETE); + }, [onPolygonChanged, onDrawEvent]); + + 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: Partial, // Feature from geojson + mapEquipments: MapEquipments | null, + geoData: GeoData | null +) { + const polygonCoordinates = features?.geometry; + if ( + !geoData || + !polygonCoordinates || + polygonCoordinates.type !== 'Polygon' || + polygonCoordinates.coordinates[0].length < 3 + ) { + return []; + } + //get the list of substation + const substationsList = mapEquipments?.substations ?? []; + //for each substation, check if it is in the polygon + return substationsList // keep only the sybstation in the polygon + .filter((substation) => { + const pos = geoData.getSubstationPosition(substation.id); + return booleanPointInPolygon(pos, polygonCoordinates); + }); +} + +function getSelectedLinesInPolygon( + network: MapEquipments | null, + lines: Line[], + geoData: GeoData | null, + polygonCoordinates: Polygon +) { + return lines.filter((line) => { + try { + const linePos = network ? geoData?.getLinePositions(network, line) : null; + if (!linePos) { + return false; + } + if (linePos.length < 2) { + return false; + } + const extremities = [linePos[0], linePos[linePos.length - 1]]; + return extremities.some((pos) => booleanPointInPolygon(pos, polygonCoordinates)); + } catch (error) { + console.error(error); + return false; + } + }); +} diff --git a/src/components/network-map-viewer/network/substation-layer.js b/src/components/network-map-viewer/network/substation-layer.ts similarity index 72% rename from src/components/network-map-viewer/network/substation-layer.js rename to src/components/network-map-viewer/network/substation-layer.ts index e882b412..34f2de81 100644 --- a/src/components/network-map-viewer/network/substation-layer.js +++ b/src/components/network-map-viewer/network/substation-layer.ts @@ -5,12 +5,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { CompositeLayer, TextLayer } from 'deck.gl'; +import { Color, CompositeLayer, LayerContext, TextLayer, UpdateParameters } from 'deck.gl'; import ScatterplotLayerExt from './layers/scatterplot-layer-ext'; import { SUBSTATION_RADIUS, SUBSTATION_RADIUS_MAX_PIXEL, SUBSTATION_RADIUS_MIN_PIXEL } from './constants'; +import { Substation, VoltageLevel } from '../utils/equipment-types'; +import { MapEquipments } from './map-equipments'; +import { GeoData } from './geo-data'; -const voltageLevelNominalVoltageIndexer = (map, voltageLevel) => { +const voltageLevelNominalVoltageIndexer = (map: Map, voltageLevel: VoltageLevel) => { let list = map.get(voltageLevel.nominalV); if (!list) { list = []; @@ -20,9 +23,31 @@ const voltageLevelNominalVoltageIndexer = (map, voltageLevel) => { return map; }; -export class SubstationLayer extends CompositeLayer { - initializeState() { - super.initializeState(); +type MetaVoltageLevel = { + nominalVoltageIndex: number; + voltageLevels: VoltageLevel[]; +}; + +type MetaVoltageLevelsByNominalVoltage = { + nominalV: number; + metaVoltageLevels: MetaVoltageLevel[]; +}; + +export type SubstationLayerProps = { + data: Substation[]; + network: MapEquipments; + geoData: GeoData; + getNominalVoltageColor: (nominalV: number) => Color; + filteredNominalVoltages: number[] | null; + labelsVisible: boolean; + labelColor: Color; + labelSize: number; + getNameOrId: (infos: Substation) => string | null; +}; + +export class SubstationLayer extends CompositeLayer { + initializeState(context: LayerContext) { + super.initializeState(context); this.state = { compositeData: [], @@ -30,16 +55,20 @@ export class SubstationLayer extends CompositeLayer { }; } - updateState({ props, oldProps, changeFlags }) { + updateState({ + props: { data, filteredNominalVoltages, geoData, getNameOrId, network }, + oldProps, + changeFlags, + }: UpdateParameters) { if (changeFlags.dataChanged) { - let metaVoltageLevelsByNominalVoltage = new Map(); + const metaVoltageLevelsByNominalVoltage = new Map(); - if (props.network != null && props.geoData != null) { + if (network != null && geoData != null) { // create meta voltage levels // a meta voltage level is made of: // - a list of voltage level that belong to same substation and with same nominal voltage // - index of the voltage levels nominal voltage in the substation nominal voltage list - props.data.forEach((substation) => { + data.forEach((substation) => { // index voltage levels of this substation by its nominal voltage (this is because we might // have several voltage levels with the same nominal voltage in the same substation) const voltageLevelsByNominalVoltage = substation.voltageLevels.reduce( @@ -88,18 +117,17 @@ export class SubstationLayer extends CompositeLayer { if ( changeFlags.dataChanged || - props.getNameOrId !== oldProps.getNameOrId || - props.filteredNominalVoltages !== oldProps.filteredNominalVoltages + getNameOrId !== oldProps.getNameOrId || + filteredNominalVoltages !== oldProps.filteredNominalVoltages ) { - let substationsLabels = props.data; + let substationsLabels = data; - if (props.network != null && props.geoData != null && props.filteredNominalVoltages != null) { + if (network != null && geoData != null && filteredNominalVoltages != null) { // we construct the substations where there is at least one voltage level with a nominal voltage // present in the filteredVoltageLevels property, in order to handle correctly the substations labels visibility substationsLabels = substationsLabels.filter( (substation) => - substation.voltageLevels.find((v) => props.filteredNominalVoltages.includes(v.nominalV)) !== - undefined + substation.voltageLevels.find((v) => filteredNominalVoltages.includes(v.nominalV)) !== undefined ); } @@ -111,18 +139,19 @@ export class SubstationLayer extends CompositeLayer { const layers = []; // substations : create one layer per nominal voltage, starting from higher to lower nominal voltage - this.state.metaVoltageLevelsByNominalVoltage.forEach((e) => { + this.state.metaVoltageLevelsByNominalVoltage.forEach((e: MetaVoltageLevelsByNominalVoltage) => { const substationsLayer = new ScatterplotLayerExt( this.getSubLayerProps({ id: 'NominalVoltage' + e.nominalV, data: e.metaVoltageLevels, radiusMinPixels: SUBSTATION_RADIUS_MIN_PIXEL, - getRadiusMaxPixels: (metaVoltageLevel) => + getRadiusMaxPixels: (metaVoltageLevel: MetaVoltageLevel) => SUBSTATION_RADIUS_MAX_PIXEL * (metaVoltageLevel.nominalVoltageIndex + 1), - getPosition: (metaVoltageLevel) => + getPosition: (metaVoltageLevel: MetaVoltageLevel) => this.props.geoData.getSubstationPosition(metaVoltageLevel.voltageLevels[0].substationId), getFillColor: this.props.getNominalVoltageColor(e.nominalV), - getRadius: (voltageLevel) => SUBSTATION_RADIUS * (voltageLevel.nominalVoltageIndex + 1), + getRadius: (voltageLevel: MetaVoltageLevel) => + SUBSTATION_RADIUS * (voltageLevel.nominalVoltageIndex + 1), visible: !this.props.filteredNominalVoltages || this.props.filteredNominalVoltages.includes(e.nominalV), updateTriggers: { @@ -138,8 +167,8 @@ export class SubstationLayer extends CompositeLayer { this.getSubLayerProps({ id: 'Label', data: this.state.substationsLabels, - getPosition: (substation) => this.props.geoData.getSubstationPosition(substation.id), - getText: (substation) => this.props.getNameOrId(substation), + getPosition: (substation: Substation) => this.props.geoData.getSubstationPosition(substation.id), + getText: (substation: Substation) => this.props.getNameOrId(substation), getColor: this.props.labelColor, fontFamily: 'Roboto', getSize: this.props.labelSize, diff --git a/src/components/network-map-viewer/utils/equipment-types.ts b/src/components/network-map-viewer/utils/equipment-types.ts index 0bc25e77..ca796144 100644 --- a/src/components/network-map-viewer/utils/equipment-types.ts +++ b/src/components/network-map-viewer/utils/equipment-types.ts @@ -5,6 +5,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { LineStatus } from '../network/line-layer'; + export const EQUIPMENT_INFOS_TYPES = { LIST: { type: 'LIST' }, MAP: { type: 'MAP' }, @@ -13,22 +15,107 @@ export const EQUIPMENT_INFOS_TYPES = { TOOLTIP: { type: 'TOOLTIP' }, }; -export const EQUIPMENT_TYPES = { - SUBSTATION: 'SUBSTATION', - VOLTAGE_LEVEL: 'VOLTAGE_LEVEL', - LINE: 'LINE', - TWO_WINDINGS_TRANSFORMER: 'TWO_WINDINGS_TRANSFORMER', - THREE_WINDINGS_TRANSFORMER: 'THREE_WINDINGS_TRANSFORMER', - HVDC_LINE: 'HVDC_LINE', - GENERATOR: 'GENERATOR', - BATTERY: 'BATTERY', - LOAD: 'LOAD', - SHUNT_COMPENSATOR: 'SHUNT_COMPENSATOR', - TIE_LINE: 'TIE_LINE', - DANGLING_LINE: 'DANGLING_LINE', - STATIC_VAR_COMPENSATOR: 'STATIC_VAR_COMPENSATOR', - HVDC_CONVERTER_STATION: 'HVDC_CONVERTER_STATION', - VSC_CONVERTER_STATION: 'VSC_CONVERTER_STATION', - LCC_CONVERTER_STATION: 'LCC_CONVERTER_STATION', - SWITCH: 'SWITCH', +export enum EQUIPMENT_TYPES { + SUBSTATION = 'SUBSTATION', + VOLTAGE_LEVEL = 'VOLTAGE_LEVEL', + LINE = 'LINE', + TWO_WINDINGS_TRANSFORMER = 'TWO_WINDINGS_TRANSFORMER', + THREE_WINDINGS_TRANSFORMER = 'THREE_WINDINGS_TRANSFORMER', + HVDC_LINE = 'HVDC_LINE', + GENERATOR = 'GENERATOR', + BATTERY = 'BATTERY', + LOAD = 'LOAD', + SHUNT_COMPENSATOR = 'SHUNT_COMPENSATOR', + TIE_LINE = 'TIE_LINE', + DANGLING_LINE = 'DANGLING_LINE', + STATIC_VAR_COMPENSATOR = 'STATIC_VAR_COMPENSATOR', + HVDC_CONVERTER_STATION = 'HVDC_CONVERTER_STATION', + VSC_CONVERTER_STATION = 'VSC_CONVERTER_STATION', + LCC_CONVERTER_STATION = 'LCC_CONVERTER_STATION', + SWITCH = 'SWITCH', +} + +export type LonLat = [number, number]; + +export type VoltageLevel = { + id: string; + nominalV: number; + substationId: string; + substationName?: string; +}; + +export type Substation = { + id: string; + name: string; + voltageLevels: VoltageLevel[]; +}; + +export const isVoltageLevel = (object: Record): object is VoltageLevel => 'substationId' in object; + +export const isSubstation = (object: Record): object is Substation => 'voltageLevels' in object; + +export type Line = { + id: string; + voltageLevelId1: string; + voltageLevelId2: string; + name: string; + terminal1Connected: boolean; + terminal2Connected: boolean; + p1: number; + p2: number; + i1?: number; + i2?: number; + operatingStatus?: LineStatus; + currentLimits1?: { + permanentLimit: number; + } | null; + currentLimits2?: { + permanentLimit: number; + } | null; + // additionnal from line-layer + origin?: LonLat; + end?: LonLat; + substationIndexStart?: number; + substationIndexEnd?: number; + angle?: number; + angleStart?: number; + angleEnd?: number; + proximityFactorStart?: number; + proximityFactorEnd?: number; + parallelIndex?: number; + cumulativeDistances?: number[]; + positions?: LonLat[]; +}; + +export const isLine = (object: Record): object is Line => + 'id' in object && 'voltageLevelId1' in object && 'voltageLevelId2' in object; + +export type TieLine = { + id: string; +}; + +export enum ConvertersMode { + SIDE_1_RECTIFIER_SIDE_2_INVERTER, + SIDE_1_INVERTER_SIDE_2_RECTIFIER, +} + +export type HvdcLine = { + id: string; + convertersMode: ConvertersMode; + r: number; + nominalV: number; + activePowerSetpoint: number; + maxP: number; +}; + +export type Equipment = Line | Substation | TieLine | HvdcLine; + +// type EquimentLineTypes = EQUIPMENT_TYPES.LINE | EQUIPMENT_TYPES.TIE_LINE | EQUIPMENT_TYPES.HVDC_LINE; +export type LineEquimentLine = Line & { equipmentType: EQUIPMENT_TYPES.LINE }; +export type TieLineEquimentLine = Line & { + equipmentType: EQUIPMENT_TYPES.TIE_LINE; +}; +export type HvdcLineEquimentLine = Line & { + equipmentType: EQUIPMENT_TYPES.HVDC_LINE; }; +export type EquimentLine = LineEquimentLine | TieLineEquimentLine | HvdcLineEquimentLine; diff --git a/src/deckgl.d.ts b/src/deckgl.d.ts index 2d990b4b..8418d85f 100644 --- a/src/deckgl.d.ts +++ b/src/deckgl.d.ts @@ -8,9 +8,95 @@ /* eslint-disable */ /* Override for v8 following - * https://deck.gl/docs/get-started/using-with-typescript + * https://deck.gl/docs/get-started/using-with-typescript#deckgl-v8 + * TODO: remove this file when migrating to deck.gl v9 */ declare module 'deck.gl' { //export namespace DeckTypings {} export * from 'deck.gl/typed'; } +declare module '@deck.gl/aggregation-layers' { export * from '@deck.gl/aggregation-layers/typed'; } +declare module '@deck.gl/carto' { export * from '@deck.gl/carto/typed'; } +declare module '@deck.gl/core' { export * from '@deck.gl/core/typed'; } +declare module '@deck.gl/extensions' { export * from '@deck.gl/extensions/typed'; } +declare module '@deck.gl/geo-layers' { export * from '@deck.gl/geo-layers/typed'; } +declare module '@deck.gl/google-maps' { export * from '@deck.gl/google-maps/typed'; } +declare module '@deck.gl/json' { export * from '@deck.gl/json/typed'; } +declare module '@deck.gl/layers' { export * from '@deck.gl/layers/typed'; } +declare module '@deck.gl/mapbox' { export * from '@deck.gl/mapbox/typed'; } +declare module '@deck.gl/mesh-layers' { export * from '@deck.gl/mesh-layers/typed'; } +declare module '@deck.gl/react' { export * from '@deck.gl/react/typed'; } + +/* For @luma.gl v8, the best would be to use @danmarshall/deckgl-typings work, but it conflicts with "@deck.gl//typed"... + * Has we will migrate to deck.gl v9 very soon, it's acceptable to just let typescript not check types temporally. + * TODO: remove this file when migrating to deck.gl v9 + */ +declare module '@luma.gl/core' { + // just shut down tsc with 'any' + export { Model, Geometry } from '@luma.gl/engine'; + export function isWebGL2(gl: any): boolean; + export function hasFeatures(gl: any, features: any): any; + export class Texture2D extends Resource { + static isSupported(gl: any, opts: any): boolean; + constructor(gl: any, props?: {}); + toString(): string; + initialize(props?: {}): this | void; + get handle(): any; + delete({ deleteChildren }?: { deleteChildren?: boolean }): this | void; + getParameter(pname: any, opts?: {}): any; + getParameters(opts?: {}): {}; + setParameter(pname: any, value: any): this; + setParameters(parameters: any): this; + stubRemovedMethods(className: any, version: any, methodNames: any): void; + resize({ height, width, mipmaps }: { height: any; width: any; mipmaps?: boolean }): this; + generateMipmap(params?: {}): this; + setImageData(options: any): this; + setSubImageData(args: { + target?: any; + pixels?: any; + data?: any; + x?: number; + y?: number; + width?: any; + height?: any; + level?: number; + format?: any; + type?: any; + dataFormat?: any; + compressed?: boolean; + offset?: number; + border?: any; + parameters?: {}; + }): void; + copyFramebuffer(opts?: {}): any; + getActiveUnit(): number; + bind(textureUnit?: any): any; + unbind(textureUnit?: any): any; + } + export const FEATURES: { + WEBGL2: string; + VERTEX_ARRAY_OBJECT: string; + TIMER_QUERY: string; + INSTANCED_RENDERING: string; + MULTIPLE_RENDER_TARGETS: string; + ELEMENT_INDEX_UINT32: string; + BLEND_EQUATION_MINMAX: string; + FLOAT_BLEND: string; + COLOR_ENCODING_SRGB: string; + TEXTURE_DEPTH: string; + TEXTURE_FLOAT: string; + TEXTURE_HALF_FLOAT: string; + TEXTURE_FILTER_LINEAR_FLOAT: string; + TEXTURE_FILTER_LINEAR_HALF_FLOAT: string; + TEXTURE_FILTER_ANISOTROPIC: string; + COLOR_ATTACHMENT_RGBA32F: string; + COLOR_ATTACHMENT_FLOAT: string; + COLOR_ATTACHMENT_HALF_FLOAT: string; + GLSL_FRAG_DATA: string; + GLSL_FRAG_DEPTH: string; + GLSL_DERIVATIVES: string; + GLSL_TEXTURE_LOD: string; + }; + //export type TextureFormat = any; + //export type UniformValue = any; +}