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; export type ScadaAnalysisRenderResult = { rendered_ids: string[]; missing_ids: string[]; level_counts: ScadaAnalysisLevelCounts; fitted: true; }; export const SCADA_ANALYSIS_LEVEL_COLORS: Record = { high: "#F43F5E", medium: "#F59E0B", low: "#10B981", unrated: "#8B5CF6" }; const SCADA_ANALYSIS_LABEL_COLORS: Record = { high: "#991B1B", medium: "#92400E", low: "#166534", unrated: "#5B21B6" }; const levelColorExpression = ( colors: Record ): ExpressionSpecification => [ "match", ["get", "analysis_level"], "high", colors.high, "medium", colors.medium, "low", colors.low, colors.unrated ]; export const SCADA_ANALYSIS_LEVEL_LABELS: Record = { high: "高", medium: "中", low: "低", unrated: "未分级" }; const SCADA_ANALYSIS_SORT_PRIORITY: Record = { high: 0, medium: 1, low: 2, unrated: 3 }; type Layer = NonNullable[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 ): 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 { 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(); 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; 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; result: Omit } { const featuresBySensorId = new Map>(); 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); } }); const levelCounts = createEmptyScadaAnalysisLevelCounts(); const renderedIds: string[] = []; const missingIds: string[] = []; const features: Array> = []; 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 }; }