Files
next-tjwater-frontend/src/features/workbench/map/workbench-map-controller.ts
T
jiang f4318a9b9c feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00

532 lines
17 KiB
TypeScript

import type { Feature, FeatureCollection, 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 {
clearPipeFlowCalculations as clearFlowCalculations,
createFlowOverlayState,
hideFlowOverlay,
replacePipeFlowCalculations as replaceFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation as updateFlowCalculation,
type FlowOverlayState,
type PipeFlowCalculation,
type PipeFlowSummary
} from "./flow-overlay";
import {
MAX_PIPE_FEATURES,
MAX_MAP_FEATURE_IDS,
createMapFeatureWfsUrl,
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "./map-feature-query";
import {
applyValueLabelStyle,
clearValueLabels,
type ValueLabelSourceId,
type ValueLabelStyle
} 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_FLOW_CALCULATION"
| "INVALID_STYLE_PATCH";
export type WorkbenchMapControllerState = {
target: FeatureTarget | null;
valueLabels: Partial<Record<ValueLabelSourceId, ValueLabelStyle>>;
pending: boolean;
errorCode: WorkbenchMapErrorCode | null;
flowVisible: boolean;
flowSummary: PipeFlowSummary;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
export type WorkbenchMapCommands = {
zoomToFeature: (target: FeatureTarget) => Promise<void>;
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
replacePipeFlowCalculations: (records: readonly PipeFlowCalculation[]) => Promise<PipeFlowSummary | null>;
updatePipeFlowCalculation: (record: PipeFlowCalculation) => PipeFlowSummary | null;
clearPipeFlowCalculations: () => Promise<void>;
showValueLabel: (request: ValueLabelRequest) => Promise<void>;
clearValueLabel: (sourceId?: ValueLabelSourceId) => 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,
valueLabels: {},
pending: false,
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
};
private listeners = new Set<() => void>();
private highlightedTarget: FeatureTarget | null = null;
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private scadaAnalysisRevision = 0;
constructor(private readonly options: WorkbenchMapControllerOptions) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
this.moveCameraToFeature(map, feature);
}
async locateAndHighlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
if (!this.moveCameraToFeature(map, feature)) return;
this.setHighlight(target);
}
private moveCameraToFeature(map: MapLibreMap, feature: Feature) {
const coordinates = collectCoordinates(feature.geometry);
if (!coordinates.length) {
this.setError("FEATURE_NOT_FOUND");
return false;
}
const duration = this.options.reducedMotion?.() ? 0 : 600;
if (feature.geometry.type === "Point") {
map.easeTo({
center: coordinates[0] as [number, number],
zoom: 18,
padding: this.options.getPadding(),
duration
});
} 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
});
}
this.patchState({ errorCode: null });
return true;
}
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.options.reducedMotion?.()
);
this.patchState({ pending: false, flowVisible: true });
} catch {
hideFlowOverlay(map, this.flowState);
this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" });
}
}
async replacePipeFlowCalculations(
records: readonly PipeFlowCalculation[]
): Promise<PipeFlowSummary | null> {
const map = this.requireMap();
if (!map) return null;
const revision = ++this.flowUpdateRevision;
this.patchState({ pending: true, errorCode: null });
try {
const summary = await replaceFlowCalculations(map, this.flowState, records);
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
}
return summary;
} catch {
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
}
return null;
}
}
updatePipeFlowCalculation(
record: PipeFlowCalculation
): PipeFlowSummary | null {
const map = this.requireMap();
if (!map) return null;
this.flowUpdateRevision += 1;
try {
const summary = updateFlowCalculation(map, this.flowState, record);
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
return summary;
} catch {
this.patchState({
pending: false,
errorCode: "INVALID_FLOW_CALCULATION"
});
return null;
}
}
async clearPipeFlowCalculations() {
const map = this.requireMap();
if (!map) return;
const revision = ++this.flowUpdateRevision;
this.patchState({ pending: true, errorCode: null });
try {
await clearFlowCalculations(map, this.flowState);
if (revision === this.flowUpdateRevision) {
this.patchState({
pending: false,
flowSummary: createEmptyPipeFlowSummary(),
errorCode: null
});
}
} catch {
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
}
}
}
async showValueLabel(request: ValueLabelRequest) {
const map = this.requireMap();
if (!map) return;
const style: ValueLabelStyle = { ...request, unit: request.unit ?? "" };
try {
applyValueLabelStyle(map, style);
this.patchState({
valueLabels: {
...this.state.valueLabels,
[style.sourceId]: style
},
errorCode: null
});
} catch {
this.setError("INVALID_STYLE_PATCH");
}
}
clearValueLabel = (sourceId?: ValueLabelSourceId) => {
const map = this.options.getMap();
if (map) clearValueLabels(map, sourceId);
if (!sourceId) {
this.patchState({ valueLabels: {} });
return;
}
const valueLabels = { ...this.state.valueLabels };
delete valueLabels[sourceId];
this.patchState({ valueLabels });
};
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);
this.flowUpdateRevision += 1;
void clearFlowCalculations(map, this.flowState);
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
}
this.patchState({
target: null,
valueLabels: {},
pending: false,
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
});
};
destroy() {
this.flowUpdateRevision += 1;
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 parsed = parseMapFeatureQuery({ sourceId, featureIds });
if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
headers: { Accept: "application/geo+json, application/json" },
signal
});
if (!response.ok) throw new Error("WFS_UNAVAILABLE");
const maxFeatures = featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES;
const collection = normalizeMapFeatureCollection(await response.json(), maxFeatures);
if (!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.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 createEmptyPipeFlowSummary(): PipeFlowSummary {
return { total: 0, forward: 0, reverse: 0, stopped: 0, invalid: 0 };
}
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;
}