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_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", "device_type"], ""]]], "pipe_flow", SCADA_IMAGE_IDS.flow, "pressure", SCADA_IMAGE_IDS.pressure, SCADA_IMAGE_IDS.pressure ]; export const SCADA_LAYER_CONTROL_ITEMS = [ { id: "scada-pressure", label: "压力监测点", description: "83 个压力监测点", deviceType: "pressure", imageId: SCADA_IMAGE_IDS.pressure, color: MAP_STYLE_TOKENS.supply.scadaPressure }, { id: "scada-flow", label: "流量监测点", description: "35 个流量监测点", deviceType: "pipe_flow", imageId: SCADA_IMAGE_IDS.flow, color: MAP_STYLE_TOKENS.supply.scadaFlow } ] as const; export type ScadaLayerControlId = (typeof SCADA_LAYER_CONTROL_ITEMS)[number]["id"]; 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 ]; type Layer = NonNullable[number]; export type ScadaImageRegistrationResult = { status: "ready" | "degraded" | "unavailable" | "cancelled"; failedImageIds: string[]; }; const typeFilter = (deviceType: string): ExpressionSpecification => [ "==", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]], deviceType ]; function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number]) { const filter = typeFilter(definition.deviceType); const color = createSupplyStateColor(definition.color); const business: Layer[] = [ { id: `${definition.id}-casing`, type: "circle", source: SCADA_SOURCE_ID, filter, 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 } }, { id: `${definition.id}-fill`, type: "circle", source: SCADA_SOURCE_ID, filter, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.radius, "circle-color": color, "circle-opacity": supplyFacilityOpacityExpression } }, { id: `${definition.id}-symbol`, type: "symbol", source: SCADA_SOURCE_ID, filter, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, layout: { "icon-image": definition.imageId, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true }, paint: { "icon-opacity": supplyIconOpacityExpression } } ]; const fallback: Layer[] = [ ...business.slice(0, 2), { id: `${definition.id}-fallback`, type: "circle", source: SCADA_SOURCE_ID, filter, 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 } } ]; const interaction: Layer[] = [ { id: `${definition.id}-hover`, type: "circle", source: SCADA_SOURCE_ID, filter, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": definition.color, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, { id: `${definition.id}-selected-outer`, type: "circle", source: SCADA_SOURCE_ID, filter, 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: `${definition.id}-selected`, type: "circle", source: SCADA_SOURCE_ID, filter, 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 hit: Layer = { id: `${definition.id}-hit`, type: "circle", source: SCADA_SOURCE_ID, filter, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 } }; return { business, fallback, interaction, hit }; } const groups = SCADA_LAYER_CONTROL_ITEMS.map((definition) => ({ definition, layers: createScadaLayers(definition) })); export const scadaBusinessLayers = groups.flatMap((group) => group.layers.business); export const scadaFallbackBusinessLayers = groups.flatMap((group) => group.layers.fallback); export const scadaInteractionLayers = groups.flatMap((group) => group.layers.interaction); export const scadaHitLayers = groups.map((group) => group.layers.hit); export const SCADA_HIT_LAYER_IDS = scadaHitLayers.map((layer) => layer.id); export const SCADA_HIT_LAYER_ID = SCADA_HIT_LAYER_IDS[0]; export const SCADA_LAYER_GROUPS = Object.fromEntries(groups.map(({ definition, layers }) => [ definition.id, [ ...layers.business.map((layer) => layer.id), ...layers.fallback.map((layer) => layer.id), ...layers.interaction.map((layer) => layer.id), layers.hit.id ] ])) as Record; export const scadaLayers = [ ...scadaBusinessLayers, ...scadaInteractionLayers, ...scadaHitLayers ]; export const scadaFallbackLayers = [ ...scadaFallbackBusinessLayers, ...scadaInteractionLayers, ...scadaHitLayers ]; export async function registerScadaImages( map: Pick, signal?: AbortSignal ): Promise { if (signal?.aborted) return { status: "cancelled", failedImageIds: [] }; let pressure: Awaited>; try { pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure); if (signal?.aborted) return { status: "cancelled", failedImageIds: [] }; 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); if (signal?.aborted) return { status: "cancelled", failedImageIds }; addImageIfMissing(map, imageId, image); } catch { if (signal?.aborted) return { status: "cancelled", failedImageIds }; 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 }); }