default user UI

This commit is contained in:
taDuc
2026-05-10 21:34:21 +07:00
parent b14f11574b
commit c371d70993
5 changed files with 1363 additions and 196 deletions
+192 -8
View File
@@ -53,6 +53,18 @@ type MapProps = {
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
};
export type MapHoverPayload = {
featureId: string | number;
feature: Feature | null;
point: { x: number; y: number };
lngLat: { lng: number; lat: number };
};
type EngineBinding = {
@@ -82,6 +94,11 @@ export default function Map({
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
onHoverFeatureChange,
highlightFeatures = null,
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
}: MapProps) {
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -102,10 +119,15 @@ export default function Map({
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
// Mirror of geometry visibility for type filtering.
const geometryVisibilityRef = useRef<MapProps["geometryVisibility"]>(geometryVisibility);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures);
const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection);
const focusRequestKeyRef = useRef<MapProps["focusRequestKey"]>(focusRequestKey);
const focusPaddingRef = useRef<MapProps["focusPadding"]>(focusPadding);
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
// Mirror của callback onSelectFeatureId.
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Mirror của callback onCreateFeature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Mirror của callback onDeleteFeature.
@@ -206,6 +228,10 @@ export default function Map({
selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]);
useEffect(() => {
onHoverFeatureChangeRef.current = onHoverFeatureChange;
}, [onHoverFeatureChange]);
useEffect(() => {
if (mode !== "select" || selectedFeatureId === null) {
editingEngineRef.current?.clearEditing();
@@ -228,12 +254,24 @@ export default function Map({
}, [backgroundVisibility]);
useEffect(() => {
geometryVisibilityRef.current = geometryVisibility;
// When toggling geometry types, refresh sources immediately (without waiting for parent re-mount).
highlightFeaturesRef.current = highlightFeatures;
const map = mapRef.current;
if (!map) return;
applyDraftToMap(draftRef.current);
}, [geometryVisibility]);
if (!map || !map.isStyleLoaded()) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}, [highlightFeatures]);
useEffect(() => {
focusFeatureCollectionRef.current = focusFeatureCollection;
}, [focusFeatureCollection]);
useEffect(() => {
focusRequestKeyRef.current = focusRequestKey;
}, [focusRequestKey]);
useEffect(() => {
focusPaddingRef.current = focusPadding;
}, [focusPadding]);
useEffect(() => {
onCreateRef.current = onCreateFeature;
@@ -299,6 +337,22 @@ export default function Map({
}
}, []);
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
if (!source) return;
source.setData(fc);
}, []);
useEffect(() => {
geometryVisibilityRef.current = geometryVisibility;
const map = mapRef.current;
if (!map) return;
applyDraftToMap(draftRef.current);
}, [applyDraftToMap, geometryVisibility]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
// Nếu đang "fit to draft bounds" thì không nên override center.
@@ -899,7 +953,56 @@ export default function Map({
},
});
map.addSource("entity-focus", {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
});
map.addLayer({
id: "entity-focus-fill",
type: "fill",
source: "entity-focus",
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#fde047",
"fill-opacity": 0.2,
},
});
map.addLayer({
id: "entity-focus-line",
type: "line",
source: "entity-focus",
paint: {
"line-color": "#f59e0b",
"line-width": [
"interpolate",
["linear"],
["zoom"],
1, 2.4,
4, 4,
6, 5.5,
],
"line-opacity": 0.98,
},
});
map.addLayer({
id: "entity-focus-points",
type: "circle",
source: "entity-focus",
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#f8fafc",
"circle-radius": 8,
"circle-stroke-color": "#f59e0b",
"circle-stroke-width": 3,
"circle-opacity": 1,
},
});
addPointSymbolLayer(map);
applyHighlightToMap(highlightFeaturesRef.current || EMPTY_FEATURE_COLLECTION);
// init drawing
const drawingEngine = initDrawing(
@@ -1047,10 +1150,59 @@ export default function Map({
() => map.off("zoom", syncZoomLevel),
];
const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
const callback = onHoverFeatureChangeRef.current;
if (!callback) return;
const selectableLayers = getSelectableLayers(map);
if (!selectableLayers.length) {
callback(null);
return;
}
const features = map.queryRenderedFeatures(event.point, {
layers: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
const feature = features[0];
const rawFeatureId = feature?.id ?? feature?.properties?.id;
if (rawFeatureId === undefined || rawFeatureId === null) {
callback(null);
return;
}
const currentFeature =
draftRef.current.features.find(
(item) => String(item.properties.id) === String(rawFeatureId)
) || null;
callback({
featureId: rawFeatureId,
feature: currentFeature,
point: { x: event.point.x, y: event.point.y },
lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
});
};
const handleCanvasMouseLeave = () => {
onHoverFeatureChangeRef.current?.(null);
};
map.on("mousemove", handleHoverMove);
mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
mapCleanupFnsRef.current.push(() => {
map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
});
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
// Khi vao web, thu auto center theo vi tri user (neu co quyen).
tryCenterToUserLocation();
if (focusRequestKeyRef.current !== null && focusRequestKeyRef.current !== undefined && focusFeatureCollectionRef.current?.features.length) {
fitMapToFeatureCollection(map, focusFeatureCollectionRef.current, focusPaddingRef.current);
}
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
@@ -1072,7 +1224,7 @@ export default function Map({
}
map.remove();
};
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
}, [allowGeometryEditing, applyDraftToMap, applyHighlightToMap, tryCenterToUserLocation]);
useEffect(() => {
const map = mapRef.current;
@@ -1126,6 +1278,15 @@ export default function Map({
}
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const target = focusFeatureCollectionRef.current;
if (!target || !target.features.length) return;
fitMapToFeatureCollection(map, target, focusPaddingRef.current);
}, [focusRequestKey]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
@@ -1374,6 +1535,19 @@ function createRasterBaseLayer() {
};
}
function getSelectableLayers(map: maplibregl.Map): string[] {
return [
"countries-fill",
"countries-line",
"routes-line",
"routes-path-arrow-fill",
"routes-path-arrow-line",
"routes-path-hit",
"places-circle",
"places-symbol",
].filter((layerId) => Boolean(map.getLayer(layerId)));
}
function filterDraftByBinding(
fc: FeatureCollection,
selectedFeatureId: string | number | null
@@ -1470,16 +1644,26 @@ function setSelectedFeatureState(
}
}
function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): boolean {
function fitMapToFeatureCollection(
map: maplibregl.Map,
fc: FeatureCollection,
padding?: number | maplibregl.PaddingOptions
): boolean {
const bbox = getFeatureCollectionBBox(fc);
if (!bbox) return false;
const resolvedPadding =
typeof padding === "number" || padding
? padding
: 58;
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
if (lngSpan < 0.000001 && latSpan < 0.000001) {
map.easeTo({
center: [bbox.minLng, bbox.minLat],
zoom: 6,
padding: resolvedPadding,
duration: 0,
});
return true;
@@ -1491,7 +1675,7 @@ function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection):
[bbox.maxLng, bbox.maxLat],
],
{
padding: 58,
padding: resolvedPadding,
maxZoom: 7,
duration: 0,
}