282 lines
8.8 KiB
TypeScript
282 lines
8.8 KiB
TypeScript
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<StyleSpecification["layers"]>[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<ScadaLayerControlId, string[]>;
|
|
|
|
export const scadaLayers = [
|
|
...scadaBusinessLayers,
|
|
...scadaInteractionLayers,
|
|
...scadaHitLayers
|
|
];
|
|
|
|
export const scadaFallbackLayers = [
|
|
...scadaFallbackBusinessLayers,
|
|
...scadaInteractionLayers,
|
|
...scadaHitLayers
|
|
];
|
|
|
|
export async function registerScadaImages(
|
|
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
|
|
signal?: AbortSignal
|
|
): Promise<ScadaImageRegistrationResult> {
|
|
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
|
|
let pressure: Awaited<ReturnType<typeof loadImage>>;
|
|
|
|
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<keyof typeof SCADA_IMAGE_IDS, "pressure">];
|
|
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<MapLibreMap, "loadImage">, path: string) {
|
|
const response = await map.loadImage(path);
|
|
return response.data;
|
|
}
|
|
|
|
function addImageIfMissing(
|
|
map: Pick<MapLibreMap, "addImage" | "hasImage">,
|
|
imageId: string,
|
|
image: Awaited<ReturnType<typeof loadImage>>
|
|
) {
|
|
if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 });
|
|
}
|