import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl"; import { MAP_STYLE_TOKENS } from "./map-colors"; import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals"; import { createSupplyStateColor, supplyFacilityOpacityExpression, supplyIconOpacityExpression } from "./supply-style-expressions"; export const SCADA_SOURCE_ID = "scada"; export const SCADA_SOURCE_LAYER = "geo_scada"; export const SCADA_HIT_LAYER_ID = "scada-hit"; export const SCADA_ICON_PATHS = { flow: "/map/scada-flow.png", pressure: "/map/scada-pressure.png" } as const; export const SCADA_MAP_ICON_PATHS = SCADA_ICON_PATHS; export const SCADA_IMAGE_IDS = { flow: "scada-flow", pressure: "scada-pressure" } as const; export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ "match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], "pipe_flow", SCADA_IMAGE_IDS.flow, "pressure", SCADA_IMAGE_IDS.pressure, SCADA_IMAGE_IDS.pressure ]; const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [ "case", state === "selected" ? ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]] : ["boolean", ["feature-state", state], false], 1, 0 ]; const scadaColor: ExpressionSpecification = [ "match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], "pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow, MAP_STYLE_TOKENS.supply.scadaPressure ]; type Layer = NonNullable[number]; export type ScadaImageRegistrationResult = { status: "ready" | "degraded" | "unavailable"; failedImageIds: string[]; }; const scadaCasingLayer: Layer = { id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius, "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-opacity": 0.94 } }; const scadaFillLayer: Layer = { id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.radius, "circle-color": createSupplyStateColor(scadaColor), "circle-opacity": supplyFacilityOpacityExpression } }; const scadaSymbolLayer: Layer = { id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true }, paint: { "icon-opacity": supplyIconOpacityExpression } }; const scadaFallbackLayer: Layer = { id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2], "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-opacity": supplyIconOpacityExpression } }; export const scadaInteractionLayers: Layer[] = [ { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, { id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } } ]; const scadaHitLayer: Layer = { id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 } }; export const scadaBusinessLayers: Layer[] = [ scadaCasingLayer, scadaFillLayer, scadaSymbolLayer ]; export const scadaFallbackBusinessLayers: Layer[] = [ scadaCasingLayer, scadaFillLayer, scadaFallbackLayer ]; export const scadaHitLayers: Layer[] = [scadaHitLayer]; export const scadaLayers: Layer[] = [ ...scadaBusinessLayers, ...scadaInteractionLayers, scadaHitLayer ]; export const scadaFallbackLayers: Layer[] = [ ...scadaFallbackBusinessLayers, ...scadaInteractionLayers, scadaHitLayer ]; export async function registerScadaImages( map: Pick ): Promise { let pressure: Awaited>; try { pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure); addImageIfMissing(map, SCADA_IMAGE_IDS.pressure, pressure); } catch { return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) }; } const failedImageIds: string[] = []; for (const [key, path] of Object.entries(SCADA_MAP_ICON_PATHS)) { if (key === "pressure") continue; const imageId = SCADA_IMAGE_IDS[key as Exclude]; if (map.hasImage(imageId)) continue; try { const image = await loadImage(map, path); addImageIfMissing(map, imageId, image); } catch { failedImageIds.push(imageId); try { addImageIfMissing(map, imageId, pressure); } catch { return { status: "unavailable", failedImageIds }; } } } return { status: failedImageIds.length > 0 ? "degraded" : "ready", failedImageIds }; } async function loadImage(map: Pick, path: string) { const response = await map.loadImage(path); return response.data; } function addImageIfMissing( map: Pick, imageId: string, image: Awaited> ) { if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 }); }