215 lines
6.7 KiB
TypeScript
215 lines
6.7 KiB
TypeScript
import type { Feature, FeatureCollection, Point } from "geojson";
|
|
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
|
|
|
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
|
export const SCADA_ANALYSIS_LAYER_IDS = {
|
|
casing: "agent-scada-analysis-casing",
|
|
level: "agent-scada-analysis-level",
|
|
indicator: "agent-scada-analysis-indicator",
|
|
label: "agent-scada-analysis-label"
|
|
} as const;
|
|
|
|
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
|
|
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
|
|
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel };
|
|
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
|
|
export type ScadaAnalysisRenderResult = {
|
|
rendered_ids: string[];
|
|
missing_ids: string[];
|
|
level_counts: ScadaAnalysisLevelCounts;
|
|
fitted: true;
|
|
};
|
|
|
|
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
|
high: "#F43F5E",
|
|
medium: "#F59E0B",
|
|
low: "#10B981",
|
|
unrated: "#8B5CF6"
|
|
};
|
|
|
|
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
|
high: "#991B1B",
|
|
medium: "#92400E",
|
|
low: "#166534",
|
|
unrated: "#5B21B6"
|
|
};
|
|
|
|
const levelColorExpression = (
|
|
colors: Record<ScadaAnalysisLevel, string>
|
|
): ExpressionSpecification => [
|
|
"match",
|
|
["get", "analysis_level"],
|
|
"high", colors.high,
|
|
"medium", colors.medium,
|
|
"low", colors.low,
|
|
colors.unrated
|
|
];
|
|
|
|
export const SCADA_ANALYSIS_LEVEL_LABELS: Record<ScadaAnalysisLevel, string> = {
|
|
high: "高",
|
|
medium: "中",
|
|
low: "低",
|
|
unrated: "未分级"
|
|
};
|
|
|
|
const SCADA_ANALYSIS_SORT_PRIORITY: Record<ScadaAnalysisLevel, number> = {
|
|
high: 0,
|
|
medium: 1,
|
|
low: 2,
|
|
unrated: 3
|
|
};
|
|
|
|
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
|
|
|
export const scadaAnalysisLayers: Layer[] = [
|
|
{
|
|
id: SCADA_ANALYSIS_LAYER_IDS.casing,
|
|
type: "circle",
|
|
source: SCADA_ANALYSIS_SOURCE_ID,
|
|
paint: {
|
|
"circle-radius": 20,
|
|
"circle-color": "rgba(255,255,255,0)",
|
|
"circle-stroke-color": "#FFFFFF",
|
|
"circle-stroke-width": 8
|
|
}
|
|
},
|
|
{
|
|
id: SCADA_ANALYSIS_LAYER_IDS.level,
|
|
type: "circle",
|
|
source: SCADA_ANALYSIS_SOURCE_ID,
|
|
paint: {
|
|
"circle-radius": 20,
|
|
"circle-color": "rgba(255,255,255,0)",
|
|
"circle-stroke-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
|
|
"circle-stroke-width": 5
|
|
}
|
|
},
|
|
{
|
|
id: SCADA_ANALYSIS_LAYER_IDS.indicator,
|
|
type: "circle",
|
|
source: SCADA_ANALYSIS_SOURCE_ID,
|
|
paint: {
|
|
"circle-radius": 6,
|
|
"circle-translate": [14, -14],
|
|
"circle-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
|
|
"circle-stroke-color": "#FFFFFF",
|
|
"circle-stroke-width": 2
|
|
}
|
|
},
|
|
{
|
|
id: SCADA_ANALYSIS_LAYER_IDS.label,
|
|
type: "symbol",
|
|
source: SCADA_ANALYSIS_SOURCE_ID,
|
|
layout: {
|
|
"symbol-sort-key": ["get", "sort_priority"],
|
|
"text-field": ["get", "analysis_label"],
|
|
"text-size": 18,
|
|
"text-font": ["Noto Sans Regular"],
|
|
"text-letter-spacing": 0.015,
|
|
"text-offset": [0, -2.2],
|
|
"text-anchor": "bottom",
|
|
"text-allow-overlap": false,
|
|
"text-ignore-placement": false
|
|
},
|
|
paint: {
|
|
"text-color": levelColorExpression(SCADA_ANALYSIS_LABEL_COLORS),
|
|
"text-halo-color": "#FFFFFF",
|
|
"text-halo-width": 4,
|
|
"text-halo-blur": 0.5
|
|
}
|
|
}
|
|
];
|
|
|
|
export function ensureScadaAnalysisLayers(
|
|
map: Pick<MapLibreMap, "addLayer" | "getLayer">
|
|
): void {
|
|
scadaAnalysisLayers.forEach((layer, index) => {
|
|
if (map.getLayer(layer.id)) return;
|
|
const beforeId = scadaAnalysisLayers
|
|
.slice(index + 1)
|
|
.map((candidate) => candidate.id)
|
|
.find((candidateId) => Boolean(map.getLayer(candidateId)));
|
|
map.addLayer(layer, beforeId);
|
|
});
|
|
}
|
|
|
|
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
|
return { type: "FeatureCollection", features: [] };
|
|
}
|
|
|
|
export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | null {
|
|
if (!Array.isArray(value) || value.length < 1 || value.length > 100) return null;
|
|
const items: ScadaAnalysisItem[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
|
const keys = Object.keys(entry);
|
|
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
|
|
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
|
|
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
|
|
if (
|
|
!sensorId ||
|
|
sensorId.length > 128 ||
|
|
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
|
|
seen.has(sensorId)
|
|
) return null;
|
|
seen.add(sensorId);
|
|
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function createScadaAnalysisCollection(
|
|
collection: FeatureCollection,
|
|
items: ScadaAnalysisItem[]
|
|
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
|
|
const featuresBySensorId = new Map<string, Feature<Point>>();
|
|
collection.features.forEach((feature) => {
|
|
const sensorId = typeof feature.properties?.sensor_id === "string"
|
|
? feature.properties.sensor_id.trim()
|
|
: "";
|
|
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
|
|
featuresBySensorId.set(sensorId, feature as Feature<Point>);
|
|
}
|
|
});
|
|
|
|
const levelCounts = createEmptyScadaAnalysisLevelCounts();
|
|
const renderedIds: string[] = [];
|
|
const missingIds: string[] = [];
|
|
const features: Array<Feature<Point>> = [];
|
|
items.forEach((item) => {
|
|
const feature = featuresBySensorId.get(item.sensor_id);
|
|
if (!feature) {
|
|
missingIds.push(item.sensor_id);
|
|
return;
|
|
}
|
|
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
|
|
if (!trustedSensorId) {
|
|
missingIds.push(item.sensor_id);
|
|
return;
|
|
}
|
|
renderedIds.push(trustedSensorId);
|
|
levelCounts[item.level] += 1;
|
|
features.push({
|
|
...feature,
|
|
properties: {
|
|
...feature.properties,
|
|
sensor_id: trustedSensorId,
|
|
analysis_level: item.level,
|
|
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
|
|
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
|
|
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
collection: { type: "FeatureCollection", features },
|
|
result: { rendered_ids: renderedIds, missing_ids: missingIds, level_counts: levelCounts }
|
|
};
|
|
}
|
|
|
|
export function createEmptyScadaAnalysisLevelCounts(): ScadaAnalysisLevelCounts {
|
|
return { high: 0, medium: 0, low: 0, unrated: 0 };
|
|
}
|