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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
|
||||
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 {
|
||||
@@ -9,11 +9,29 @@ import {
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "./layer-group-style";
|
||||
import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay";
|
||||
import {
|
||||
VALUE_LABEL_SOURCE_ID,
|
||||
createEmptyValueLabelCollection,
|
||||
createValueLabelCollection
|
||||
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,
|
||||
@@ -32,25 +50,32 @@ export type WorkbenchMapErrorCode =
|
||||
| "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 = FeatureTarget & { property: string; precision: number; unit?: string };
|
||||
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>;
|
||||
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
|
||||
clearValueLabel: () => 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>;
|
||||
@@ -67,11 +92,21 @@ export type WorkbenchMapControllerOptions = {
|
||||
};
|
||||
|
||||
export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null };
|
||||
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) {}
|
||||
@@ -83,22 +118,48 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
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) return this.setError("FEATURE_NOT_FOUND");
|
||||
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: 600 });
|
||||
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: 600 });
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration
|
||||
});
|
||||
}
|
||||
this.setHighlight(target);
|
||||
this.patchState({ errorCode: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
async highlight(target: FeatureTarget) {
|
||||
@@ -106,14 +167,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
if (feature) this.setHighlight(target);
|
||||
}
|
||||
|
||||
clearHighlight() {
|
||||
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();
|
||||
@@ -128,7 +189,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
await showFlowOverlay(
|
||||
map,
|
||||
this.flowState,
|
||||
() => this.fetchFeatures("pipes"),
|
||||
this.options.reducedMotion?.()
|
||||
);
|
||||
this.patchState({ pending: false, flowVisible: true });
|
||||
@@ -138,25 +198,96 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
async replacePipeFlowCalculations(
|
||||
records: readonly PipeFlowCalculation[]
|
||||
): Promise<PipeFlowSummary | null> {
|
||||
const map = this.requireMap();
|
||||
if (!feature || !map) return;
|
||||
if (!map) return null;
|
||||
const revision = ++this.flowUpdateRevision;
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
try {
|
||||
(map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
createValueLabelCollection(feature, property, precision, unit)
|
||||
);
|
||||
this.patchState({ target, errorCode: null });
|
||||
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() {
|
||||
clearValueLabel = (sourceId?: ValueLabelSourceId) => {
|
||||
const map = this.options.getMap();
|
||||
(map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection());
|
||||
}
|
||||
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();
|
||||
@@ -173,12 +304,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
||||
}
|
||||
|
||||
async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise<ScadaAnalysisRenderResult> {
|
||||
void items;
|
||||
void signal;
|
||||
this.setError("SCADA_FEATURES_NOT_FOUND");
|
||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
||||
/*
|
||||
async renderScadaAnalysis(
|
||||
items: ScadaAnalysisItem[],
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaAnalysisRenderResult> {
|
||||
const revision = ++this.scadaAnalysisRevision;
|
||||
const map = this.options.getMap();
|
||||
if (!map || !this.options.isReady()) {
|
||||
@@ -189,21 +318,36 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
let sourceCollection: FeatureCollection;
|
||||
try {
|
||||
sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal);
|
||||
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");
|
||||
throw new Error(
|
||||
revision === this.scadaAnalysisRevision
|
||||
? "WFS_UNAVAILABLE"
|
||||
: "ACTION_SUPERSEDED"
|
||||
);
|
||||
}
|
||||
if (revision !== this.scadaAnalysisRevision) {
|
||||
throw new Error("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" });
|
||||
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;
|
||||
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");
|
||||
@@ -216,9 +360,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
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);
|
||||
|
||||
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]],
|
||||
@@ -236,14 +390,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
],
|
||||
[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
|
||||
});
|
||||
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() {
|
||||
@@ -256,19 +415,31 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
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, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
|
||||
}
|
||||
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();
|
||||
@@ -299,15 +470,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
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 }),
|
||||
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
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
|
||||
return body as FeatureCollection;
|
||||
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) {
|
||||
@@ -316,7 +489,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -339,6 +511,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user