feat: enable feature selection for preview modes and add click interaction support to the selecting engine
This commit is contained in:
+76
-15
@@ -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%",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 có 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user