350 lines
13 KiB
TypeScript
350 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 {
|
|
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("/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<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;
|
|
}
|