Files
next-tjwater-frontend/src/features/workbench/hooks/use-map-interactions.ts
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

163 lines
5.5 KiB
TypeScript

import type { Map as MapLibreMap, MapLayerMouseEvent, PointLike } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import type { DetailFeature } from "../types";
import {
getFeatureId,
getWaterNetworkSourceId,
toDetailFeature
} from "../map/feature-adapter";
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
import { CITY_BUILDING_LAYER_ID } from "../map/city-scene-layers";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
source: WaterNetworkSourceId;
sourceLayer?: string;
id: string;
hovered: boolean;
selected: boolean;
};
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 feature.layer === "scada"
? { source: feature.layer, id: feature.id }
: { 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(toFeatureStateTarget(feature), state);
}
export function clearMapFeatureInteractionState(
map: FeatureStateMap,
feature: FeatureReference,
key?: "hovered" | "selected"
) {
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type CityOcclusionMap = Pick<
MapLibreMap,
"getLayer" | "getPitch" | "queryRenderedFeatures"
>;
export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: PointLike) {
if (map.getPitch() <= 0 || !map.getLayer(CITY_BUILDING_LAYER_ID)) return false;
return map.queryRenderedFeatures(point, { layers: [CITY_BUILDING_LAYER_ID] }).length > 0;
}
type UseMapInteractionsOptions = {
enabled?: boolean;
availableSourceIds: readonly WaterNetworkSourceId[];
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
};
export function useMapInteractions({
enabled = true,
availableSourceIds,
mapRef,
mapReady,
onSelectFeature,
selectedFeature
}: UseMapInteractionsOptions) {
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !enabled) return;
let hoveredFeature: FeatureReference | null = null;
const clearHoveredFeature = () => {
if (!hoveredFeature) return;
clearMapFeatureInteractionState(map, hoveredFeature, "hovered");
hoveredFeature = null;
};
const handleMouseMove = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) {
clearHoveredFeature();
map.getCanvas().style.cursor = "";
return;
}
const feature = event.features?.[0];
const source = feature && getWaterNetworkSourceId(feature);
const id = feature && getFeatureId(feature);
if (!source || !id) return;
const next = source === "scada"
? { source, id }
: { source, sourceLayer: SOURCE_LAYERS[source], id };
if (selectedFeature?.id === id && selectedFeature.layer === source) {
clearHoveredFeature();
return;
}
if (hoveredFeature?.id === id && hoveredFeature.source === source) return;
clearHoveredFeature();
setMapFeatureInteractionState(map, next, { hovered: true });
hoveredFeature = next;
map.getCanvas().style.cursor = "pointer";
};
const handleMouseLeave = () => {
clearHoveredFeature();
map.getCanvas().style.cursor = "";
};
const handleClick = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) return;
const feature = event.features?.[0];
if (feature) onSelectFeature(toDetailFeature(feature));
};
const hitLayerIds = INTERACTIVE_HIT_LAYER_IDS.filter((layerId) => map.getLayer(layerId));
map.on("mousemove", hitLayerIds, handleMouseMove);
map.on("mouseleave", hitLayerIds, handleMouseLeave);
map.on("click", hitLayerIds, handleClick);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
if (mapRef.current !== map) return;
clearHoveredFeature();
map.getCanvas().style.cursor = "";
map.off("mousemove", hitLayerIds, handleMouseMove);
map.off("mouseleave", hitLayerIds, handleMouseLeave);
map.off("click", hitLayerIds, handleClick);
};
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature, enabled]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
const next = toMapFeatureReference(selectedFeature);
if (next && map.getSource(next.source)) {
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
}
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
if (mapRef.current === map && next && map.getSource(next.source)) {
clearMapFeatureInteractionState(map, next, "selected");
}
};
}, [availableSourceIds, mapReady, mapRef, selectedFeature]);
}
function toFeatureStateTarget(feature: FeatureReference) {
return feature.sourceLayer
? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }
: { source: feature.source, id: feature.id };
}