import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson"; import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl"; import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; import { LAYER_GROUP_GEOMETRIES, applyLayerGroupStyle as applyStyle, resetLayerGroupStyle as resetStyle, validateLayerGroupStylePatch, type LayerGroupId, type LayerGroupStylePatch } from "./layer-group-style"; import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay"; import { VALUE_LABEL_SOURCE_ID, createEmptyValueLabelCollection, createValueLabelCollection } from "./value-label"; import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection, createScadaAnalysisCollection, ensureScadaAnalysisLayers, type ScadaAnalysisItem, type ScadaAnalysisRenderResult } from "./scada-analysis"; export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string }; export type WorkbenchMapErrorCode = | "MAP_NOT_READY" | "FEATURE_NOT_FOUND" | "WFS_UNAVAILABLE" | "SCADA_FEATURES_NOT_FOUND" | "ACTION_SUPERSEDED" | "FLOW_UNAVAILABLE" | "INVALID_STYLE_PATCH"; export type WorkbenchMapControllerState = { target: FeatureTarget | null; pending: boolean; errorCode: WorkbenchMapErrorCode | null; flowVisible: boolean; styledGroups: LayerGroupId[]; scadaAnalysis: ScadaAnalysisRenderResult | null; }; export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string }; export type WorkbenchMapCommands = { locateAndHighlight: (target: FeatureTarget) => Promise; highlight: (target: FeatureTarget) => Promise; clearHighlight: () => void; setFlowVisible: (visible: boolean) => Promise; showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise; clearValueLabel: () => void; applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void; resetLayerGroupStyle: (groupId: LayerGroupId) => void; renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise; clearScadaAnalysis: () => { cleared: true }; resetAll: () => void; }; export type WorkbenchMapControllerOptions = { getMap: () => MapLibreMap | null; isReady: () => boolean; getPadding: () => PaddingOptions; fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise; reducedMotion?: () => boolean; }; export class WorkbenchMapController implements WorkbenchMapCommands { private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null }; private listeners = new Set<() => void>(); private highlightedTarget: FeatureTarget | null = null; private cachedFeatures = new Map(); private flowState: FlowOverlayState = createFlowOverlayState(); private scadaAnalysisRevision = 0; constructor(private readonly options: WorkbenchMapControllerOptions) {} subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; getSnapshot = () => this.state; async locateAndHighlight(target: FeatureTarget) { const feature = await this.resolveTarget(target); const map = this.requireMap(); if (!feature || !map) return; const coordinates = collectCoordinates(feature.geometry); if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND"); if (feature.geometry.type === "Point") { map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 }); } else { const bounds = coordinates.reduce( (acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])], [Infinity, Infinity, -Infinity, -Infinity] ); map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 }); } this.setHighlight(target); } async highlight(target: FeatureTarget) { const feature = await this.resolveTarget(target); if (feature) this.setHighlight(target); } clearHighlight() { const map = this.options.getMap(); if (map && this.highlightedTarget) { map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); } this.highlightedTarget = null; this.patchState({ target: null, errorCode: null }); } async setFlowVisible(visible: boolean) { const map = this.requireMap(); if (!map) return; if (!visible) { hideFlowOverlay(map, this.flowState); this.patchState({ flowVisible: false, errorCode: null }); return; } this.patchState({ pending: true, errorCode: null }); try { await showFlowOverlay( map, this.flowState, () => this.fetchFeatures("conduits"), this.options.reducedMotion?.() ); this.patchState({ pending: false, flowVisible: true }); } catch { hideFlowOverlay(map, this.flowState); this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" }); } } async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) { if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH"); const feature = await this.resolveTarget(target); const map = this.requireMap(); if (!feature || !map) return; try { (map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData( createValueLabelCollection(feature, property, precision, unit) ); this.patchState({ target, errorCode: null }); } catch { this.setError("INVALID_STYLE_PATCH"); } } clearValueLabel() { const map = this.options.getMap(); (map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection()); } applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) { const map = this.requireMap(); if (!map) return; if (!validateLayerGroupStylePatch(groupId, patch)) return this.setError("INVALID_STYLE_PATCH"); applyStyle(map, groupId, patch); this.patchState({ styledGroups: [...new Set([...this.state.styledGroups, groupId])], errorCode: null }); } resetLayerGroupStyle(groupId: LayerGroupId) { const map = this.requireMap(); if (!map || !(groupId in LAYER_GROUP_GEOMETRIES)) return; resetStyle(map, groupId); this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null }); } async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise { const revision = ++this.scadaAnalysisRevision; const map = this.options.getMap(); if (!map || !this.options.isReady()) { this.setError("MAP_NOT_READY"); throw new Error("MAP_NOT_READY"); } this.patchState({ pending: true, errorCode: null }); let sourceCollection: FeatureCollection; try { sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal); } catch { if (revision === this.scadaAnalysisRevision) { this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); } throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED"); } if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED"); const next = createScadaAnalysisCollection(sourceCollection, items); if (next.collection.features.length === 0) { this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" }); throw new Error("SCADA_FEATURES_NOT_FOUND"); } const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined; if (!source) { this.patchState({ pending: false, errorCode: "MAP_NOT_READY" }); throw new Error("MAP_NOT_READY"); } try { ensureScadaAnalysisLayers(map); source.setData(next.collection); } catch { this.patchState({ pending: false, errorCode: "MAP_NOT_READY" }); throw new Error("MAP_NOT_READY"); } const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true }; this.patchState({ pending: false, errorCode: null, scadaAnalysis: result }); const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates); if (coordinates.length === 1) { map.easeTo({ center: [coordinates[0][0], coordinates[0][1]], zoom: 18, padding: this.options.getPadding(), duration: this.options.reducedMotion?.() ? 0 : 600 }); } else { const bounds = coordinates.reduce<[number, number, number, number]>( (value, coordinate) => [ Math.min(value[0], coordinate[0]), Math.min(value[1], coordinate[1]), Math.max(value[2], coordinate[0]), Math.max(value[3], coordinate[1]) ], [Infinity, Infinity, -Infinity, -Infinity] ); map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: this.options.reducedMotion?.() ? 0 : 600 }); } return result; } clearScadaAnalysis() { this.scadaAnalysisRevision += 1; const map = this.options.getMap(); (map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData( createEmptyScadaAnalysisCollection() ); this.patchState({ scadaAnalysis: null, pending: false, errorCode: null }); return { cleared: true as const }; } resetAll() { this.clearHighlight(); this.clearValueLabel(); this.clearScadaAnalysis(); const map = this.options.getMap(); if (map) { hideFlowOverlay(map, this.flowState); (Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId)); } this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null }); } destroy() { const map = this.options.getMap(); if (map) hideFlowOverlay(map, this.flowState); this.listeners.clear(); } private async resolveTarget(target: FeatureTarget) { const map = this.requireMap(); if (!map) return null; const key = `${target.sourceId}:${target.featureId}`; const cached = this.cachedFeatures.get(key); if (cached) return cached; this.patchState({ pending: true, errorCode: null }); try { const collection = await this.fetchFeatures(target.sourceId, [target.featureId]); const feature = collection.features[0]; if (!feature) { this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" }); return null; } this.cachedFeatures.set(key, feature); this.patchState({ pending: false }); return feature; } catch { this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); return null; } } private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) { if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal); const response = await fetch("/api/map-features", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceId, featureIds }), signal }); const body = await response.json(); if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE"); return body as FeatureCollection; } private setHighlight(target: FeatureTarget) { const map = this.requireMap(); if (!map) return; if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); map.setFeatureState(toFeatureStateTarget(target), { highlighted: true }); this.highlightedTarget = target; this.clearValueLabel(); this.patchState({ target, errorCode: null }); } private requireMap() { const map = this.options.getMap(); if (!map || !this.options.isReady()) { this.setError("MAP_NOT_READY"); return null; } return map; } private setError(errorCode: WorkbenchMapErrorCode) { this.patchState({ errorCode }); } private patchState(patch: Partial) { this.state = { ...this.state, ...patch }; this.listeners.forEach((listener) => listener()); } } function toFeatureStateTarget(target: FeatureTarget) { return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId }; } function collectCoordinates(geometry: Geometry | null): Position[] { if (!geometry || geometry.type === "GeometryCollection") return []; const coordinates: Position[] = []; const visit = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as Position); else if (Array.isArray(value)) value.forEach(visit); }; visit(geometry.coordinates); return coordinates; }