feat: migrate drainage frontend to Vite
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user