feat: enable feature selection for preview modes and add click interaction support to the selecting engine

This commit is contained in:
taDuc
2026-05-26 01:26:47 +07:00
parent de91f8129e
commit 8306543828
20 changed files with 1356 additions and 332 deletions
+76 -15
View File
@@ -13,7 +13,7 @@ import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
export type MapHoverPayload = {
export type MapFeaturePayload = {
featureId: string | number;
feature: Feature | null;
point: { x: number; y: number };
@@ -29,6 +29,7 @@ export type MapHandle = {
projection: string;
} | null;
getMap: () => import("maplibre-gl").Map | null;
setGlobeProjection: (isGlobe: boolean) => void;
};
type MapProps = {
@@ -53,8 +54,7 @@ type MapProps = {
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined;
highlightFeatures?: FeatureCollection | null;
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
@@ -62,6 +62,10 @@ type MapProps = {
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
showViewportControls?: boolean;
isPreviewMode?: boolean;
onEnterPreview?: () => void;
onExitPreview?: () => void;
onPlayPreviewReplay?: () => void;
};
const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -83,8 +87,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
onHoverFeatureChange,
highlightFeatures = null,
onFeatureClick,
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
@@ -92,6 +95,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onImageOverlayChange,
onBindGeometries,
showViewportControls = true,
isPreviewMode = false,
onEnterPreview,
onExitPreview,
onPlayPreviewReplay,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
@@ -101,8 +108,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
const onSetModeRef = useRef(onSetMode);
// Ref callback hover mới nhất cho tooltip/panel ngoài map.
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Ref callback click feature mới nhất cho tooltip/panel ngoài map.
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback delete mới nhất khi editing engine xóa feature.
@@ -122,7 +129,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
@@ -153,7 +160,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useImperativeHandle(ref, () => ({
getViewState,
getMap: () => mapRef.current,
}), [getViewState, mapRef]);
setGlobeProjection: (isGlobe: boolean) => {
setIsGlobeProjection(isGlobe);
},
}), [getViewState, mapRef, setIsGlobeProjection]);
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const {
@@ -173,14 +183,13 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onDeleteRef,
onHideRef,
onUpdateRef,
onHoverFeatureChangeRef,
onFeatureClickRef,
onBindGeometriesRef,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyRenderDraftToMap,
applyHighlightToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
@@ -194,7 +203,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
focusFeatureCollection,
focusRequestKey,
focusPadding,
@@ -208,7 +216,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const map = mapRef.current;
if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
setupMapLayers(map, backgroundVisibility);
applyImageOverlayToMap();
setupMapInteractions(map);
applyRenderDraftToMap(renderDraftRef.current);
@@ -363,9 +371,62 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
>
{isGlobeProjection ? "Globe" : "Flat"}
</span>
</label>
</label>
<button
{onEnterPreview || onExitPreview ? (
<button
type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "76px",
padding: "0 12px",
background: isPreviewMode ? "#334155" : "#166534",
fontWeight: 800,
}}
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
>
{isPreviewMode ? "Editor" : "Preview"}
</button>
) : null}
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "64px",
padding: "0 12px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "7px",
background: "#2563eb",
fontSize: "13px",
fontWeight: 800,
}}
aria-label="Play selected replay"
title="Play replay của geometry đang chọn"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
borderLeft: "8px solid currentColor",
}}
/>
Play
</button>
) : null}
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
style={zoomButtonStyle}
@@ -194,41 +194,21 @@ export default function ReplayPreviewLayerPanel({
outline: "none",
});
const renderTooltipStyles = () => (
const renderStyles = () => (
<style dangerouslySetInnerHTML={{ __html: `
.${buttonClassName} {
position: relative;
.replay-preview-layer-panel::-webkit-scrollbar {
display: none;
}
.${buttonClassName}::after {
content: attr(data-tooltip);
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%) scale(0.9);
margin-left: 10px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.25);
color: #f8fafc;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(2, 6, 23, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 999;
}
.${buttonClassName}:hover::after {
opacity: 1;
transform: translateY(-50%) scale(1);
.replay-preview-layer-panel {
scrollbar-width: none;
-ms-overflow-style: none;
}
`}} />
);
return (
<div
className="replay-preview-layer-panel"
style={{
display: "flex",
flexDirection: "column",
@@ -237,15 +217,16 @@ export default function ReplayPreviewLayerPanel({
border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20,
padding: "14px 10px",
width: 100,
width: 58,
alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
maxHeight: "calc(100vh - 180px)",
overflowY: "auto",
overflowX: "hidden",
}}
>
{renderTooltipStyles()}
{renderStyles()}
{/* Background layers */}
<div style={groupHeaderStyle}>Map</div>
@@ -256,8 +237,7 @@ export default function ReplayPreviewLayerPanel({
<button
key={layer.id}
type="button"
className={buttonClassName}
data-tooltip={layer.label}
title={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
@@ -279,8 +259,7 @@ export default function ReplayPreviewLayerPanel({
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
@@ -302,8 +281,7 @@ export default function ReplayPreviewLayerPanel({
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
@@ -325,8 +303,7 @@ export default function ReplayPreviewLayerPanel({
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
@@ -352,7 +329,7 @@ const groupHeaderStyle: React.CSSProperties = {
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "1fr",
gap: 8,
width: "100%",
};
+32 -12
View File
@@ -150,18 +150,16 @@ export function filterDraftByBinding(
}
const childIds = new Set<string>();
const selectedChildren = new Set<string>();
const selectedParents = new Set<string>();
const parentIds = new Set<string>();
const featureParentMap = new Map<string, string>(); // childId -> parentId
for (const feature of fc.features) {
const featureId = String(feature.properties.id);
const parentId = normalizeFeatureBoundWith(feature);
if (!parentId) continue;
childIds.add(featureId);
if (selectedIds.has(parentId)) {
selectedChildren.add(featureId);
}
if (selectedIds.has(featureId)) {
selectedParents.add(parentId);
if (parentId) {
childIds.add(featureId);
parentIds.add(parentId);
featureParentMap.set(featureId, parentId);
}
}
@@ -169,13 +167,35 @@ export function filterDraftByBinding(
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
}
const activeParents = new Set<string>();
for (const id of selectedIds) {
if (parentIds.has(id)) {
activeParents.add(id);
} else {
const parentId = featureParentMap.get(id);
if (parentId) {
activeParents.add(parentId);
}
}
}
return {
...fc,
features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id);
if (selectedIds.has(featureId)) return true;
if (selectedChildren.has(featureId)) return true;
if (selectedParents.has(featureId)) return true;
const parentId = featureParentMap.get(featureId);
// 1. If this feature is a parent and its hierarchy is active, hide it
if (activeParents.has(featureId)) {
return false;
}
// 2. If this feature is a child of an active parent, show it
if (parentId && activeParents.has(parentId)) {
return true;
}
// 3. By default, hide all child geometries that are not part of the active hierarchy
return !childIds.has(featureId);
}),
};
+24 -52
View File
@@ -9,8 +9,8 @@ import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
import { MapHoverPayload } from "../Map";
import { buildClientFeatureId } from "./mapUtils";
import type { MapFeaturePayload } from "../Map";
type EngineBinding = {
cleanup: () => void;
@@ -34,7 +34,7 @@ type UseMapInteractionProps = {
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
onFeatureClickRef: React.MutableRefObject<((payload: MapFeaturePayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
};
@@ -51,7 +51,7 @@ export function useMapInteraction({
onDeleteRef,
onHideRef,
onUpdateRef,
onHoverFeatureChangeRef,
onFeatureClickRef,
onBindGeometriesRef,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
@@ -69,7 +69,7 @@ export function useMapInteraction({
}, [mapRef, onUpdateRef]);
useEffect(() => {
const allowsSelectionMode = mode === "select" || mode === "replay";
const allowsSelectionMode = mode === "select" || mode === "replay" || mode === "preview" || mode === "replay_preview";
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing();
// Clear the internal selection state of the select engine to stay in sync with React state
@@ -183,7 +183,23 @@ export function useMapInteraction({
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current),
(targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds)
(targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds),
(payload) => {
if (!payload) {
onFeatureClickRef.current?.(null);
return;
}
const currentFeature =
renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(payload.featureId)
) || null;
onFeatureClickRef.current?.({
...payload,
feature: currentFeature,
});
}
);
const cleanupPoint = initPoint(
@@ -277,7 +293,9 @@ export function useMapInteraction({
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
preview: selectEngine,
replay: selectEngine,
replay_preview: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
@@ -292,52 +310,6 @@ export function useMapInteraction({
drawingEngine.cleanup
);
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 =
renderDraftRef.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);
});
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
+1 -59
View File
@@ -30,9 +30,7 @@ export function getBaseMapStyle(): maplibregl.StyleSpecification {
export function setupMapLayers(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility,
highlightFeatures: FeatureCollection | null,
applyHighlightToMap: (fc: FeatureCollection) => void
backgroundVisibility: BackgroundLayerVisibility
) {
applyBackgroundLayerVisibility(map, backgroundVisibility);
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => {
@@ -211,62 +209,6 @@ export function setupMapLayers(
},
});
map.addSource("entity-focus", {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
});
map.addLayer({
id: "entity-focus-fill",
type: "fill",
source: "entity-focus",
filter: [
"any",
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
],
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: [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
],
paint: {
"circle-color": "#f8fafc",
"circle-radius": 8,
"circle-stroke-color": "#f59e0b",
"circle-stroke-width": 3,
"circle-opacity": 1,
},
});
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}
async function replaceBackgroundLayersWithGoong(
+5 -35
View File
@@ -34,7 +34,6 @@ type UseMapSyncProps = {
applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
@@ -58,7 +57,6 @@ export function useMapSync({
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
focusFeatureCollection,
focusRequestKey,
focusPadding,
@@ -75,7 +73,6 @@ export function useMapSync({
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
@@ -90,7 +87,6 @@ export function useMapSync({
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
@@ -102,8 +98,7 @@ export function useMapSync({
const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[],
highlightFeaturesOverride?: FeatureCollection | null
selectedIdsOverride?: (string | number)[]
) => {
const map = mapRef.current;
if (!map) return;
@@ -122,12 +117,9 @@ export function useMapSync({
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
? highlightFeaturesOverride
: highlightFeaturesRef.current;
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
? filterDraftByBinding(renderFc, currentSelectedIds)
: renderFc;
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
@@ -157,15 +149,7 @@ export function useMapSync({
}
}, [mapRef]);
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);
moveHighlightLayersToTop(map);
}, [mapRef]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
@@ -198,12 +182,7 @@ export function useMapSync({
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}, [highlightFeatures, mapRef]);
useEffect(() => {
const map = mapRef.current;
@@ -212,7 +191,7 @@ export function useMapSync({
}, [imageOverlay, mapRef]);
useEffect(() => {
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures);
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = renderDraft.features.some((f) => f.properties.id === editingId);
@@ -228,7 +207,6 @@ export function useMapSync({
selectedFeatureIds,
applyGeometryBindingFilter,
geometryVisibility,
highlightFeatures,
applyRenderDraftToMap,
editingEngineRef,
]);
@@ -266,7 +244,6 @@ export function useMapSync({
return {
applyRenderDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
const map = mapRef.current;
@@ -276,11 +253,4 @@ export function useMapSync({
};
}
function moveHighlightLayersToTop(map: maplibregl.Map) {
const layerIds = ["entity-focus-fill", "entity-focus-line", "entity-focus-points"];
for (const layerId of layerIds) {
if (map.getLayer(layerId)) {
map.moveLayer(layerId);
}
}
}
+214 -55
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import "react-quill-new/dist/quill.snow.css";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
@@ -238,34 +239,101 @@ export default function PublicWikiSidebar({
return (
<div
style={{ width: `${width}px` }}
className="relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{
width: `${width}px`,
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: 20,
border: "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
position: "relative",
}}
>
{/* Drag Handle on the left edge */}
<div
onPointerDown={handlePointerDown}
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-50 group select-none hover:bg-black/[0.03] dark:hover:bg-white/[0.02]"
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 6,
cursor: "col-resize",
zIndex: 50,
userSelect: "none",
}}
className="group"
title="Kéo để chỉnh kích thước"
>
{/* Visual drag line overlay */}
<div className="absolute left-[2px] top-0 bottom-0 w-[2px] bg-transparent group-hover:bg-brand-500/50 group-active:bg-brand-500 transition-colors" />
<div
style={{
position: "absolute",
left: 2,
top: 0,
bottom: 0,
width: 2,
background: "transparent",
transition: "background-color 0.2s",
}}
className="group-hover:bg-sky-500/50 group-active:bg-sky-500"
/>
</div>
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400">
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "16px",
}}
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
<div className="mt-1 text-lg font-semibold leading-tight text-gray-900 dark:text-gray-100">
<div
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
</div>
{entity?.description?.trim() ? (
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
<div
style={{
marginTop: 8,
fontSize: 13,
lineHeight: 1.5,
color: "#cbd5e1",
}}
>
{entity.description.trim()}
</div>
) : null}
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
<div
style={{
marginTop: 6,
fontSize: 12,
color: "#94a3b8",
}}
>
{wiki.title.trim()}
</div>
) : null}
@@ -274,7 +342,22 @@ export default function PublicWikiSidebar({
<button
type="button"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-sm text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.04] dark:hover:text-gray-100"
style={{
display: "inline-flex",
height: 28,
width: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: "1px solid rgba(148, 163, 184, 0.25)",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
cursor: "pointer",
fontSize: 12,
transition: "all 0.2s",
outline: "none",
}}
className="hover:bg-slate-700/50 hover:text-slate-100"
aria-label="Close wiki sidebar"
>
x
@@ -283,18 +366,44 @@ export default function PublicWikiSidebar({
</div>
{toc.length ? (
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="flex gap-2 overflow-x-auto pb-1">
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "8px 12px",
}}
>
<div
className="uhm-public-wiki-toc-list"
style={{
display: "flex",
gap: 8,
overflowX: "auto",
paddingBottom: 4,
}}
>
{toc.slice(0, 8).map((item) => {
const isActive = effectiveActiveHeadingId === item.id;
return (
<a
key={item.id}
href={`#${item.id}`}
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
}`}
style={{
flexShrink: 0,
borderRadius: 9999,
padding: "4px 10px",
fontSize: 11,
fontWeight: 650,
textDecoration: "none",
transition: "all 0.2s",
background: isActive
? "rgba(56, 189, 248, 0.15)"
: "rgba(30, 41, 59, 0.4)",
color: isActive ? "#38bdf8" : "#94a3b8",
border: isActive
? "1px solid rgba(56, 189, 248, 0.3)"
: "1px solid rgba(148, 163, 184, 0.1)",
}}
className={isActive ? "" : "hover:bg-slate-700/40 hover:text-slate-200"}
>
{item.text}
</a>
@@ -304,31 +413,74 @@ export default function PublicWikiSidebar({
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className="uhm-public-wiki-sidebar-content"
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
}}
>
{isLoading ? (
<div className="space-y-3 px-4 py-4">
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
<div
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "100%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "80%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
</div>
) : error ? (
<div className="px-4 py-4 text-sm text-red-600 dark:text-red-300">
<div style={{ padding: 16, fontSize: 14, color: "#f87171" }}>
{error}
</div>
) : wiki ? (
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor text-sm text-gray-900 dark:text-gray-100"
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
) : (
<div className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
Entity này chưa wiki liên kết.
</div>
)}
</div>
<style jsx global>{`
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
width: 6px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 3px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
.uhm-public-wiki-toc-list::-webkit-scrollbar {
height: 4px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 2px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
@@ -338,6 +490,7 @@ export default function PublicWikiSidebar({
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
color: #cbd5e1 !important;
}
.uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em;
@@ -347,12 +500,14 @@ export default function PublicWikiSidebar({
font-size: 1.6em;
font-weight: 800;
line-height: 1.2;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
margin: 1.05em 0 0.55em;
font-size: 1.3em;
font-weight: 800;
line-height: 1.25;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h3,
.uhm-wiki-sidebar-view.ql-editor h4,
@@ -362,6 +517,7 @@ export default function PublicWikiSidebar({
font-size: 1.05em;
font-weight: 700;
line-height: 1.3;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor ul,
.uhm-wiki-sidebar-view.ql-editor ol {
@@ -371,45 +527,53 @@ export default function PublicWikiSidebar({
.uhm-wiki-sidebar-view.ql-editor blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.6);
color: rgba(71, 85, 105, 1);
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
border-left-color: rgba(100, 116, 139, 0.6);
border-left: 3px solid rgba(148, 163, 184, 0.4);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-sidebar-view.ql-editor pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 10px;
background: rgba(248, 250, 252, 1);
background: rgba(15, 23, 42, 0.4);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
color: #cbd5e1;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4);
}
.uhm-wiki-sidebar-view.ql-editor img {
display: block !important;
max-width: 100% !important;
height: auto !important;
border-radius: 8px;
float: none !important;
margin: 1.25em auto !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.uhm-wiki-sidebar-view.ql-editor img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: left"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-left {
float: left !important;
margin: 4px 14px 14px 0px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: right"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-right {
float: right !important;
margin: 4px 0px 14px 14px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="display: block"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-center {
display: block !important;
margin: 1.25em auto !important;
}
.uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #2563eb;
color: #38bdf8 !important;
transition: color 0.15s ease;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #60a5fa;
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]):hover {
color: #7dd3fc !important;
}
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
cursor: default;
@@ -418,12 +582,7 @@ export default function PublicWikiSidebar({
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
.uhm-wiki-sidebar-view.ql-editor a[href=""],
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #dc2626;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a:not([href]),
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href=""],
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #f87171;
color: #f87171 !important;
}
@media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor {
+1
View File
@@ -166,6 +166,7 @@ Một số nguyên tắc nên giữ:
- khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết
- khi cần undo cho replay script, đi qua `editor.mutateActiveReplay()` hoặc replay collection helper hiện có
- hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng
- khi thiết kế các chế độ preview, đảm bảo khôi phục camera view state & projection (Globe/Flat) về trạng thái gốc của editor bằng cách dùng `editorOriginalMapViewStateRef` và calling `restoreEditorOriginalMapState()`.
## 12. Chỗ dễ gây hiểu nhầm khi debug
+2 -2
View File
@@ -77,7 +77,7 @@ Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/l
- Undo trong replay mode chỉ undo replay session, không undo main geometry.
- Play preview:
- Step selection chạy đúng thứ tự.
- Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản.
- Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản và projection (Globe/Flat) ban đầu.
- Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo.
## 8. Import GEO từ search
@@ -121,7 +121,7 @@ Các thao tác sau không được thêm undo action và không làm tăng pendi
- Resize panel.
- Search query.
- Pick/paste/remove image overlay trace.
- Replay preview play/stop/reset.
- Replay preview play/stop/reset (khôi phục hoàn toàn camera view state và projection của editor ban đầu).
## 12. Final smoke
+22 -1
View File
@@ -133,7 +133,7 @@ Replay state nằm trong `useEditorState()`:
- `replays` cộng overlay của `activeReplayDraft` nếu session hiện tại đã đổi nhưng chưa flush
Undo của replay session dùng stack riêng khi `mode === "replay"`.
`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist.
`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist. Khi thoát các chế độ preview, editor sẽ dọn dẹp hoàn toàn các map effects, highlight, và khôi phục camera view state & projection (Globe/Flat) ban đầu trước khi vào preview.
### 4.4. Project/session task state
@@ -185,6 +185,27 @@ Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisi
Đây là single source of truth cho phần wiki trong snapshot commit.
### 4.8. Preview session states và refs (Viewer / Replay Preview)
Các states và refs điều khiển preview được khai báo trực tiếp trong `page.tsx`:
- `previewSession: ReplayPreviewSession | null`
- Đóng băng toàn bộ snapshot collections (replays, draft, entities, wikis, links) cùng timeline, filter và camera view state khi chạy preview.
- `previewAutoplayMode: "start" | "selection" | null`
- Trạng thái autoplay (bắt đầu từ đầu hay từ step được chọn) của Replay Preview.
- `previewWikiCache`, `previewWikiError`, `isPreviewWikiLoading`
- Cache và status để hiển thị nội dung Wiki tương tác trong sidebar preview.
- `previewFeaturePopupAnchor: MapFeaturePayload | null`
- Neo tọa độ/payload của popup hiển thị thông tin geometry khi click trên map ở preview mode.
- `previewActiveEntityId`, `isPreviewEntitySidebarOpen`
- Sidebar hiển thị chi tiết entity được chọn trong preview.
- `previewLinkEntityPopup: PreviewLinkEntityPopupState | null`
- Trạng thái popup điều hướng sang entity khác khi click vào link wiki trong preview.
- `editorOriginalMapViewStateRef: ReturnType<MapHandle["getViewState"]> | null`
- Ref lưu giữ camera view state và projection (Globe/Flat) ban đầu của editor trước khi bắt đầu preview, phục vụ việc khôi phục hoàn toàn bản đồ khi exit.
- `replayPreviewReturnRef: { mode: "replay" | "preview"; session: ReplayPreviewSession | null }`
- Ref ghi nhận session và mode trước đó khi chuyển tiếp từ Viewer Preview sang Replay Preview, cho phép quay trở lại đúng Viewer Preview khi click thoát Replay Preview.
## 5. Snapshot state
Editor đang làm việc với các snapshot collection chính ngoài geometry:
@@ -8,6 +8,7 @@ export type EditorMode =
| "add-line"
| "add-path"
| "add-circle"
| "preview"
| "replay"
| "replay_preview";
+49 -10
View File
@@ -1,6 +1,12 @@
import maplibregl from "maplibre-gl";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
export type SelectFeatureClickPayload = {
featureId: string | number;
point: { x: number; y: number };
lngLat: { lng: number; lat: number };
};
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect(
map: maplibregl.Map,
@@ -12,7 +18,8 @@ export function initSelect(
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean,
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void,
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void
) {
const FEATURE_STATE_SOURCES = [
@@ -24,6 +31,8 @@ export function initSelect(
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
let cursorTimer: number | null = null;
let pendingCursorPoint: { x: number; y: number } | null = null;
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
function clearSelection(emit = true) {
@@ -38,7 +47,7 @@ export function initSelect(
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
if (id === undefined || id === null) return false;
if (!additive) {
clearSelection();
@@ -52,17 +61,19 @@ export function initSelect(
setSelectionStateForId(idToRemove, false);
selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds));
return;
return false;
}
setSelectionStateForId(id, true);
selectedIds.add(id);
onSelectIds?.(Array.from(selectedIds));
return true;
}
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
const mode = getMode();
if (mode !== "select" && mode !== "replay" && mode !== "preview" && mode !== "replay_preview") return;
if (isEditSessionActive?.()) return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
@@ -73,22 +84,37 @@ export function initSelect(
if (!features.length) {
clearSelection();
onFeatureClick?.(null);
return;
}
const additive = !!e.originalEvent?.altKey;
selectFeature(pickPreferredFeature(features), additive);
const feature = pickPreferredFeature(features);
const didSelect = selectFeature(feature, additive);
if (!didSelect) {
onFeatureClick?.(null);
return;
}
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
onFeatureClick?.({
featureId: id,
point: { x: e.point.x, y: e.point.y },
lngLat: { lng: e.lngLat.lng, lat: e.lngLat.lat },
});
}
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
const mode = getMode();
if (mode !== "select" && mode !== "replay" && mode !== "preview" && mode !== "replay_preview") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
if (mode === "replay" || mode === "preview" || mode === "replay_preview") return;
if (isEditSessionActive?.()) return;
const features = map.queryRenderedFeatures(e.point, {
@@ -122,21 +148,30 @@ export function initSelect(
}
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
function updateCursorFromPendingPoint() {
cursorTimer = null;
const mode = getMode();
if (mode !== "select" && mode !== "replay" && mode !== "preview" && mode !== "replay_preview") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) {
map.getCanvas().style.cursor = "";
return;
}
if (!pendingCursorPoint) return;
const features = map.queryRenderedFeatures(e.point, {
const features = map.queryRenderedFeatures([pendingCursorPoint.x, pendingCursorPoint.y], {
layers: selectableLayers,
});
map.getCanvas().style.cursor = features.length ? "pointer" : "";
}
function onMove(e: maplibregl.MapLayerMouseEvent) {
pendingCursorPoint = { x: e.point.x, y: e.point.y };
if (cursorTimer !== null) return;
cursorTimer = window.setTimeout(updateCursorFromPendingPoint, 40);
}
function getSelectableLayers(): string[] {
const style = map.getStyle();
if (!style || !style.layers) return [];
@@ -198,6 +233,10 @@ export function initSelect(
try {
map.off("click", onClick);
map.off("mousemove", onMove);
if (cursorTimer !== null) {
window.clearTimeout(cursorTimer);
cursorTimer = null;
}
if (hasContextActions) {
map.off("contextmenu", onRightClick);
}
@@ -9,5 +9,6 @@ export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: st
strokeColor: "#065f46",
dasharray: [4, 3],
arrowOpacity: 0.76,
showLine: false,
});
}
@@ -9,5 +9,6 @@ export function getMilitaryRouteLayers(sourceId: string, pathArrowSourceId?: str
strokeColor: "#7f1d1d",
width: { z1: 2.6, z4: 3.8, z6: 5 },
arrowOpacity: 0.9,
showLine: false,
});
}
@@ -10,5 +10,6 @@ export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: stri
dasharray: [6, 3],
opacity: 0.82,
arrowOpacity: 0.68,
showLine: false,
});
}
@@ -9,5 +9,6 @@ export function getTradeRouteLayers(sourceId: string, pathArrowSourceId?: string
strokeColor: "#854d0e",
dasharray: [5, 3],
arrowOpacity: 0.78,
showLine: false,
});
}
@@ -22,6 +22,7 @@ type LineGeotypeStyle = {
arrowOpacity?: number;
arrowOutlineColor?: string;
arrowOutlineWidth?: ZoomStops;
showLine?: boolean;
};
type PolygonGeotypeStyle = {
@@ -64,7 +65,7 @@ export function buildLineGeotypeLayers(
paint: {
"line-color": statusColor(style.color),
"line-width": widthStops(style.width ?? DEFAULT_LINE_WIDTH),
"line-opacity": style.opacity ?? 0.9,
"line-opacity": style.showLine === false ? 0 : (style.opacity ?? 0.9),
...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
},
};
+10 -4
View File
@@ -45,6 +45,7 @@ type UseReplayPreviewOptions = {
selectedStageId: number | null;
selectedStepIndex: number | null;
onSelectStep: (stageId: number | null, stepIndex: number | null) => void;
setMapProjection?: (type: "globe" | "mercator") => void;
};
export function useReplayPreview({
@@ -57,6 +58,7 @@ export function useReplayPreview({
selectedStageId,
selectedStepIndex,
onSelectStep,
setMapProjection,
}: UseReplayPreviewOptions) {
const [isPlaying, setIsPlaying] = useState(false);
const [dialog, setDialog] = useState<DialogState | null>(null);
@@ -172,9 +174,13 @@ export function useReplayPreview({
if (map) {
mapActions.restore_label_visibility(map, baseline.labelVisibility);
if (baseline.mapViewState) {
map.setProjection({
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator",
});
if (setMapProjection) {
setMapProjection(baseline.mapViewState.projection === "globe" ? "globe" : "mercator");
} else {
map.setProjection({
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator",
});
}
mapActions.set_camera_view(map, {
center: baseline.mapViewState.center,
zoom: baseline.mapViewState.zoom,
@@ -184,7 +190,7 @@ export function useReplayPreview({
});
}
}
}, [getMapInstance, resetPresentation]);
}, [getMapInstance, resetPresentation, setMapProjection]);
const resetPreview = useCallback(() => {
runIdRef.current += 1;