feat(map): refine SCADA feature experience
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMapFeatureInteractionState,
|
||||
setMapFeatureInteractionState,
|
||||
toMapFeatureReference
|
||||
} from "./use-map-interactions";
|
||||
|
||||
describe("map feature interaction state", () => {
|
||||
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
|
||||
|
||||
it("maps selected business features to promoted vector feature references", () => {
|
||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||
expect(toMapFeatureReference(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("sets hover and selected through feature-state", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
{ selected: true, hovered: false }
|
||||
);
|
||||
});
|
||||
|
||||
it("clears only the requested state key", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
clearMapFeatureInteractionState(map, feature, "selected");
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
"selected"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,23 +10,48 @@ import {
|
||||
toDetailFeature
|
||||
} from "../map/feature-adapter";
|
||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||
import { SCADA_HIT_LAYER_ID } from "../map/scada";
|
||||
|
||||
const HOVER_LAYER_IDS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||
conduits: ["pipes-hover-outline", "pipes-hover"],
|
||||
junctions: ["junctions-hover"],
|
||||
orifices: ["orifices-hover-outline", "orifices-hover"],
|
||||
pumps: ["pumps-hover-outline", "pumps-hover"],
|
||||
outfalls: ["outfalls-hover"]
|
||||
export type MapFeatureInteractionState = {
|
||||
source: WaterNetworkSourceId;
|
||||
sourceLayer: string;
|
||||
id: string;
|
||||
hovered: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
const SELECTED_LAYER_IDS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||
conduits: ["pipes-selected-halo", "pipes-selected-outline", "pipes-selected"],
|
||||
junctions: ["junctions-selected-halo", "junctions-selected"],
|
||||
orifices: ["orifices-selected-halo", "orifices-selected-outline", "orifices-selected"],
|
||||
pumps: ["pumps-selected-halo", "pumps-selected-outline", "pumps-selected"],
|
||||
outfalls: ["outfalls-selected-halo", "outfalls-selected"]
|
||||
};
|
||||
type FeatureStateMap = Pick<MapLibreMap, "setFeatureState" | "removeFeatureState">;
|
||||
type FeatureReference = Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id">;
|
||||
|
||||
export function toMapFeatureReference(
|
||||
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||
): FeatureReference | null {
|
||||
if (!feature?.id) return null;
|
||||
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||
}
|
||||
|
||||
export function setMapFeatureInteractionState(
|
||||
map: FeatureStateMap,
|
||||
feature: FeatureReference,
|
||||
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMapFeatureInteractionState(
|
||||
map: FeatureStateMap,
|
||||
feature: FeatureReference,
|
||||
key?: "hovered" | "selected"
|
||||
) {
|
||||
map.removeFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
@@ -43,88 +68,65 @@ export function useMapInteractions({
|
||||
}: UseMapInteractionsOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
if (!mapReady || !map) return;
|
||||
|
||||
const activeMap = map;
|
||||
let hoveredFeature: { id: string; sourceId: WaterNetworkSourceId } | null = null;
|
||||
let hoveredFeature: FeatureReference | null = null;
|
||||
|
||||
function clearHoveredFeature() {
|
||||
if (!hoveredFeature) {
|
||||
return;
|
||||
}
|
||||
|
||||
HOVER_LAYER_IDS[hoveredFeature.sourceId].forEach((layerId) => {
|
||||
activeMap.setFilter(layerId, ["==", ["get", "id"], ""]);
|
||||
});
|
||||
const clearHoveredFeature = () => {
|
||||
if (!hoveredFeature) return;
|
||||
clearMapFeatureInteractionState(map, hoveredFeature, "hovered");
|
||||
hoveredFeature = null;
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseMove(event: MapLayerMouseEvent) {
|
||||
const handleMouseMove = (event: MapLayerMouseEvent) => {
|
||||
const feature = event.features?.[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
const source = feature && getWaterNetworkSourceId(feature);
|
||||
const id = feature && getFeatureId(feature);
|
||||
if (!source || !id) return;
|
||||
|
||||
activeMap.getCanvas().style.cursor = "pointer";
|
||||
const id = getFeatureId(feature);
|
||||
const sourceId = getWaterNetworkSourceId(feature);
|
||||
if (!id || !sourceId) {
|
||||
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||
clearHoveredFeature();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredFeature?.id === id && hoveredFeature.sourceId === sourceId) {
|
||||
return;
|
||||
}
|
||||
if (hoveredFeature?.id === id && hoveredFeature.source === source) return;
|
||||
|
||||
clearHoveredFeature();
|
||||
HOVER_LAYER_IDS[sourceId].forEach((layerId) => {
|
||||
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
|
||||
});
|
||||
hoveredFeature = { id, sourceId };
|
||||
}
|
||||
setMapFeatureInteractionState(map, next, { hovered: true });
|
||||
hoveredFeature = next;
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
};
|
||||
|
||||
function handleMouseLeave() {
|
||||
activeMap.getCanvas().style.cursor = "";
|
||||
const handleMouseLeave = () => {
|
||||
clearHoveredFeature();
|
||||
}
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
|
||||
function handleClick(event: MapLayerMouseEvent) {
|
||||
const handleClick = (event: MapLayerMouseEvent) => {
|
||||
const feature = event.features?.[0];
|
||||
if (feature) {
|
||||
onSelectFeature(toDetailFeature(feature));
|
||||
}
|
||||
}
|
||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||
};
|
||||
|
||||
activeMap.on("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
|
||||
activeMap.on("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
|
||||
activeMap.on("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
|
||||
const hitLayerIds = [SCADA_HIT_LAYER_ID, ...INTERACTIVE_HIT_LAYER_IDS];
|
||||
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.on("click", hitLayerIds, handleClick);
|
||||
|
||||
return () => {
|
||||
activeMap.off("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
|
||||
activeMap.off("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
|
||||
activeMap.off("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
|
||||
clearHoveredFeature();
|
||||
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.off("click", hitLayerIds, handleClick);
|
||||
};
|
||||
}, [mapRef, mapReady, onSelectFeature]);
|
||||
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(SELECTED_LAYER_IDS).flat().forEach((layerId) => {
|
||||
map.setFilter(layerId, ["==", ["get", "id"], ""]);
|
||||
});
|
||||
|
||||
if (!selectedFeature?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
SELECTED_LAYER_IDS[selectedFeature.layer].forEach((layerId) => {
|
||||
map.setFilter(layerId, ["==", ["get", "id"], selectedFeature.id]);
|
||||
});
|
||||
if (!mapReady || !map) return;
|
||||
const next = toMapFeatureReference(selectedFeature);
|
||||
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||
return () => {
|
||||
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
||||
};
|
||||
}, [mapReady, mapRef, selectedFeature]);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,19 @@ import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding
|
||||
} from "../map/camera";
|
||||
import { waterNetworkLayers } from "../map/layers";
|
||||
import {
|
||||
waterNetworkBusinessLayers,
|
||||
waterNetworkHitLayers,
|
||||
waterNetworkInteractionLayers
|
||||
} from "../map/layers";
|
||||
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
import {
|
||||
registerScadaImages,
|
||||
scadaFallbackLayers,
|
||||
scadaLayers,
|
||||
type ScadaImageRegistrationResult
|
||||
} from "../map/scada";
|
||||
import {
|
||||
createBaseStyle,
|
||||
createWaterNetworkSources,
|
||||
@@ -60,6 +70,7 @@ export type WorkbenchSourceStatus = {
|
||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
"mapbox-base": "Mapbox 底图",
|
||||
"geoserver-mvt": "GeoServer MVT",
|
||||
"scada-icons": "SCADA 图标",
|
||||
"annotation-source": "业务标注源"
|
||||
};
|
||||
|
||||
@@ -71,6 +82,7 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
orifices: "geoserver-mvt",
|
||||
outfalls: "geoserver-mvt",
|
||||
pumps: "geoserver-mvt",
|
||||
scada: "geoserver-mvt",
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
};
|
||||
@@ -117,7 +129,7 @@ export function useWorkbenchMap({
|
||||
|
||||
window.__waterNetworkMap = map;
|
||||
|
||||
map.on("load", () => {
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
const sources = createWaterNetworkSources();
|
||||
map.addSource("conduits", sources.conduits);
|
||||
@@ -125,15 +137,47 @@ export function useWorkbenchMap({
|
||||
map.addSource("orifices", sources.orifices);
|
||||
map.addSource("outfalls", sources.outfalls);
|
||||
map.addSource("pumps", sources.pumps);
|
||||
map.addSource("scada", sources.scada);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
waterNetworkLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
let scadaRegistration: ScadaImageRegistrationResult = {
|
||||
status: "unavailable",
|
||||
failedImageIds: []
|
||||
};
|
||||
|
||||
setMapReady(true);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
try {
|
||||
scadaRegistration = await registerScadaImages(map);
|
||||
} finally {
|
||||
try {
|
||||
const presentationLayers = scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackLayers
|
||||
: scadaLayers;
|
||||
presentationLayers.slice(0, -1).forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
map.addLayer(presentationLayers[presentationLayers.length - 1]);
|
||||
} finally {
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
setMapReady(true);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
if (scadaRegistration.status !== "ready") {
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-icons",
|
||||
"degraded",
|
||||
scadaRegistration.status === "degraded"
|
||||
? "部分 SCADA 分类图标加载失败,已使用通用图标。"
|
||||
: "SCADA 图标加载失败,已使用通用点位显示。"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const resizeMap = () => map.resize();
|
||||
|
||||
Reference in New Issue
Block a user