feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -1,17 +1,17 @@
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions, LayerSpecification } from "maplibre-gl";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import { createScadaAnalysisCollection, createEmptyScadaAnalysisCollection, scadaAnalysisLayers, SCADA_ANALYSIS_SOURCE_ID, type ScadaAnalysisItem } from "./scada-analysis";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { createPresentationHighlightLayers, presentationFeatureSource, PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
export const INVESTIGATION_HIGHLIGHT_SOURCE = "investigation-presentation-highlights";
export const INVESTIGATION_OVERLAY_SOURCE = "investigation-presentation-overlay";
const empty = (): FeatureCollection => ({ type: "FeatureCollection", features: [] });
const color = MAP_STYLE_TOKENS.state.selected;
type OverlayOpacity = "fill-opacity" | "circle-opacity" | "circle-stroke-opacity" | "heatmap-opacity" | "line-opacity";
type OverlayOpacityValues = Partial<Record<OverlayOpacity, number>>;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-fill`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": color, "fill-opacity": 0.18 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "line", filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": color, "line-width": 5, "line-opacity": 0.85 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": color, "circle-radius": 11, "circle-opacity": 0.65, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
@@ -38,16 +38,33 @@ export class InvestigationMapPresentation {
private pulseDeadline: number | null = null;
private highlighted = empty();
private overlay: InvestigationOverlay | null = null;
private overlayFrame: number | null = null;
private overlayHideTimer: number | null = null;
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions) {}
private readonly highlightSource: string;
private readonly overlaySource: string;
private readonly scadaSource: string;
private readonly scadaLayers: LayerSpecification[];
private readonly layers: LayerSpecification[];
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions, scope = "investigation") {
this.highlightSource = `${scope}-presentation-highlights`;
this.overlaySource = `${scope}-presentation-overlay`;
this.scadaSource = `${scope}-presentation-scada`;
this.scadaLayers = scadaAnalysisLayers.map((layer) => ({ ...layer, id: layer.id.replace(SCADA_ANALYSIS_SOURCE_ID, this.scadaSource), source: this.scadaSource } as LayerSpecification));
this.layers = layers.map((layer) => ({ ...layer,
id: layer.id.replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource),
source: String("source" in layer ? layer.source : "").replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource)
} as LayerSpecification));
}
private ensure() {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) {
for (const id of [this.highlightSource, this.overlaySource]) {
if (!map.getSource(id)) map.addSource(id, { type: "geojson", data: empty() });
}
for (const layer of layers) {
for (const layer of this.layers) {
if (!map.getLayer(layer.id)) map.addLayer({ ...layer, layout: { visibility: "none" } });
}
return map;
@@ -55,17 +72,31 @@ export class InvestigationMapPresentation {
highlight(data: FeatureCollection, pulse: boolean, fit: boolean) {
this.stopPulse();
const nativeLayers = this.getMap()?.getStyle?.()?.layers;
for (const value of new Set(data.features.map((feature) => feature.properties?.[PRESENTATION_FEATURE_SOURCE]))) {
const source = presentationFeatureSource(value);
if (!source) continue;
for (const layer of createPresentationHighlightLayers(source, this.highlightSource, nativeLayers)) {
const index = this.layers.findIndex((current) => current.id === layer.id);
if (index < 0) this.layers.push(layer);
else {
this.layers[index] = layer;
const map = this.getMap();
if (map?.getLayer(layer.id) && "paint" in layer)
for (const [property, value] of Object.entries(layer.paint ?? {}))
map.setPaintProperty(layer.id, property as Parameters<MapLibreMap["setPaintProperty"]>[1], value);
}
}
}
const map = this.ensure();
this.highlighted = data;
(map.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource).setData(data);
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, data.features.length > 0);
(map.getSource(this.highlightSource) as GeoJSONSource).setData(data);
this.visibility(this.highlightSource, data.features.length > 0);
if (fit) this.fit(data);
if (pulse && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const animate = (time: number) => {
if (!map.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) return;
const wave = (Math.sin(time / 300) + 1) / 2;
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 9 + wave * 5);
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.45 + wave * 0.4);
this.highlightOpacity(0.45 + wave * 0.55);
this.pulseFrame = requestAnimationFrame(animate);
};
this.pulseFrame = requestAnimationFrame(animate);
@@ -78,40 +109,94 @@ export class InvestigationMapPresentation {
this.pulseFrame = null;
if (this.pulseDeadline !== null) window.clearTimeout(this.pulseDeadline);
this.pulseDeadline = null;
this.highlightOpacity(1);
}
private highlightOpacity(opacity: number) {
const map = this.getMap();
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 11);
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.85);
for (const layer of this.layers) {
if (!("source" in layer) || layer.source !== this.highlightSource || !map?.getLayer(layer.id)) continue;
if (layer.type === "line") map.setPaintProperty(layer.id, "line-opacity", opacity);
else if (layer.type === "circle") map.setPaintProperty(layer.id, "circle-stroke-opacity", opacity);
}
}
clearHighlights() {
this.stopPulse();
this.highlighted = empty();
(this.getMap()?.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, false);
(this.getMap()?.getSource(this.highlightSource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.highlightSource, false);
}
setOverlay(overlay: InvestigationOverlay, fit: boolean) {
const map = this.ensure();
this.overlay = overlay;
(map.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource).setData(overlay.data);
(map.getSource(this.overlaySource) as GeoJSONSource).setData(overlay.data);
this.showOverlay(overlay.visible);
if (fit) this.fit(overlay.data);
}
private cancelOverlayTransition() {
if (this.overlayFrame !== null) cancelAnimationFrame(this.overlayFrame);
if (this.overlayHideTimer !== null) window.clearTimeout(this.overlayHideTimer);
this.overlayFrame = null;
this.overlayHideTimer = null;
}
showOverlay(visible: boolean) {
this.cancelOverlayTransition();
if (!this.overlay) return;
this.overlay = { ...this.overlay, visible };
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === INVESTIGATION_OVERLAY_SOURCE)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap") ? "visible" : "none");
if (!map) return;
const duration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 180;
const entering: Array<{ id: string; opacity: OverlayOpacityValues }> = [];
const hiding: string[] = [];
const paint = (id: string, opacity: OverlayOpacityValues, shown: boolean, milliseconds: number) => {
if (!map.getLayer(id)) return;
for (const [property, value] of Object.entries(opacity) as Array<[OverlayOpacity, number]>) {
map.setPaintProperty(id, `${property}-transition` as const, { duration: milliseconds, delay: 0 });
map.setPaintProperty(id, property, shown ? value : 0);
}
};
for (const layer of this.layers.filter((item) => "source" in item && item.source === this.overlaySource)) {
if (!map.getLayer(layer.id)) continue;
const opacity: OverlayOpacityValues = layer.type === "fill" ? { "fill-opacity": 0.16 }
: layer.type === "circle" ? { "circle-opacity": 1, "circle-stroke-opacity": 1 }
: layer.type === "heatmap" ? { "heatmap-opacity": 0.65 } : { "line-opacity": 1 };
const shown = visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap");
const wasHidden = map.getLayoutProperty(layer.id, "visibility") === "none";
if (shown) {
if (duration && wasHidden) {
paint(layer.id, opacity, false, 0);
map.setLayoutProperty(layer.id, "visibility", "visible");
entering.push({ id: layer.id, opacity });
} else {
map.setLayoutProperty(layer.id, "visibility", "visible");
paint(layer.id, opacity, true, duration);
}
} else {
paint(layer.id, opacity, false, duration);
if (!duration || wasHidden) map.setLayoutProperty(layer.id, "visibility", "none");
else hiding.push(layer.id);
}
}
if (entering.length) this.overlayFrame = requestAnimationFrame(() => {
this.overlayFrame = null;
for (const layer of entering) paint(layer.id, layer.opacity, true, duration);
});
if (hiding.length) this.overlayHideTimer = window.setTimeout(() => {
this.overlayHideTimer = null;
for (const id of hiding) if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", "none");
}, duration);
}
clearOverlay(id?: string) {
if (id && id !== this.overlay?.overlay_id) return;
this.cancelOverlayTransition();
this.overlay = null;
(this.getMap()?.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_OVERLAY_SOURCE, false);
(this.getMap()?.getSource(this.overlaySource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.overlaySource, false);
}
fitOverlay() { if (this.overlay) this.fit(this.overlay.data); }
@@ -119,7 +204,7 @@ export class InvestigationMapPresentation {
private visibility(source: string, visible: boolean) {
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === source)) {
for (const layer of this.layers.filter((item) => "source" in item && item.source === source)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
@@ -128,15 +213,40 @@ export class InvestigationMapPresentation {
const positions = data.features.flatMap((feature) => feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []);
if (!positions.length) return;
const bounds = positions.reduce((value, point) => [Math.min(value[0], point[0]), Math.min(value[1], point[1]), Math.max(value[2], point[0]), Math.max(value[3], point[1])], [Infinity, Infinity, -Infinity, -Infinity]);
this.getMap()?.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.getPadding(), maxZoom: 18, duration: 0 });
const map = this.getMap();
if (!map) return;
map.stop();
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.getPadding(), maxZoom: 18, linear: true,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450,
easing: (progress: number) => 1 - (1 - progress) ** 3
});
}
clear() { this.clearHighlights(); this.clearOverlay(); }
renderScadaAnalysis(data: FeatureCollection, items: ScadaAnalysisItem[], fit = true) {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
const next = createScadaAnalysisCollection(data, items);
if (!next.result.rendered_ids.length) throw new Error("SCADA_FEATURES_NOT_FOUND");
if (!map.getSource(this.scadaSource)) map.addSource(this.scadaSource, { type: "geojson", data: createEmptyScadaAnalysisCollection() });
for (const layer of this.scadaLayers) if (!map.getLayer(layer.id)) map.addLayer(layer);
(map.getSource(this.scadaSource) as GeoJSONSource).setData(next.collection);
if (fit) this.fit(next.collection);
return { ...next.result, fitted: fit };
}
clearScadaAnalysis() {
(this.getMap()?.getSource(this.scadaSource) as GeoJSONSource | undefined)?.setData(createEmptyScadaAnalysisCollection());
return { cleared: true as const };
}
clear() { this.clearHighlights(); this.clearOverlay(); this.clearScadaAnalysis(); }
destroy() {
this.cancelOverlayTransition();
this.stopPulse();
const map = this.getMap();
if (!map) return;
for (const layer of [...layers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) if (map.getSource(id)) map.removeSource(id);
for (const layer of [...this.layers, ...this.scadaLayers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [this.highlightSource, this.overlaySource, this.scadaSource]) if (map.getSource(id)) map.removeSource(id);
}
}