import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl"; export const SCADA_SOURCE_ID = "scada"; export const SCADA_SOURCE_LAYER = "scada_points"; export const SCADA_HIT_LAYER_ID = "scada-hit"; export const SCADA_ICON_PATHS = { waterQuality: "/icons/scada-water-quality.svg", radarLevel: "/icons/scada-radar-level.svg", ultrasonicFlow: "/icons/scada-ultrasonic-flow.svg", unknown: "/icons/scada-unknown.svg" } as const; export const SCADA_MAP_ICON_PATHS = { waterQuality: "/icons/scada-water-quality.png", radarLevel: "/icons/scada-radar-level.png", ultrasonicFlow: "/icons/scada-ultrasonic-flow.png", unknown: "/icons/scada-unknown.png" } as const; export const SCADA_IMAGE_IDS = { waterQuality: "scada-water-quality", radarLevel: "scada-radar-level", ultrasonicFlow: "scada-ultrasonic-flow", unknown: "scada-unknown" } as const; export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ "match", ["to-number", ["get", "device_type_id"]], 72, SCADA_IMAGE_IDS.waterQuality, 58, SCADA_IMAGE_IDS.radarLevel, 45, SCADA_IMAGE_IDS.ultrasonicFlow, SCADA_IMAGE_IDS.unknown ]; const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [ "case", ["boolean", ["feature-state", state], false], 1, 0 ]; type Layer = NonNullable[number]; export type ScadaImageRegistrationResult = { status: "ready" | "degraded" | "unavailable"; failedImageIds: string[]; }; const scadaSymbolLayer: Layer = { id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.625, 20, 0.875], "icon-allow-overlap": true, "icon-ignore-placement": true } }; const scadaFallbackLayer: Layer = { id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], "circle-color": "#0E7490", "circle-stroke-color": "white", "circle-stroke-width": 2 } }; const scadaInteractionLayers: Layer[] = [ { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "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: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 6, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, { id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } } ]; export const scadaLayers: Layer[] = [ scadaSymbolLayer, ...scadaInteractionLayers ]; export const scadaFallbackLayers: Layer[] = [ scadaFallbackLayer, ...scadaInteractionLayers ]; export async function registerScadaImages( map: Pick ): Promise { let unknown: Awaited>; try { unknown = await loadImage(map, SCADA_MAP_ICON_PATHS.unknown); addImageIfMissing(map, SCADA_IMAGE_IDS.unknown, unknown); } 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 === "unknown") 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, unknown); } 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 }); }