Files
next-tjwater-drainage-frontend/src/features/workbench/map/workbench-map-controller.ts
T

355 lines
13 KiB
TypeScript

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 {
MAX_CONDUIT_FEATURES,
MAX_MAP_FEATURE_IDS,
createMapFeatureWfsUrl,
normalizeMapFeatureCollection
} from "./map-feature-query";
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<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
clearValueLabel: () => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
export type WorkbenchMapControllerOptions = {
getMap: () => MapLibreMap | null;
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
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<string, Feature>();
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<ScadaAnalysisRenderResult> {
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(createMapFeatureWfsUrl({ sourceId, featureIds }), { signal });
const body = await response.json();
const collection = normalizeMapFeatureCollection(
body,
featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES
);
if (!response.ok || !collection) throw new Error("WFS_UNAVAILABLE");
return collection;
}
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<WorkbenchMapControllerState>) {
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;
}