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
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Map, { type MapHoverPayload } from "@/uhm/components/Map"; import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar"; import TimelineBar from "@/uhm/components/ui/TimelineBar";
import mapLayersStyles from "@/styles/MapLayers.module.css"; import mapLayersStyles from "@/styles/MapLayers.module.css";
@@ -82,7 +82,7 @@ export default function Page() {
completed: 0, completed: 0,
total: 0, total: 0,
}); });
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null); const [hoverAnchor, setHoverAnchor] = useState<MapFeaturePayload | null>(null);
const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false); const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null); const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null); const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
@@ -369,7 +369,7 @@ export default function Page() {
}); });
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]); }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => { const handleMapHoverChange = useCallback((payload: MapFeaturePayload | null) => {
clearHoverHideTimer(); clearHoverHideTimer();
if (payload) { if (payload) {
@@ -529,8 +529,8 @@ export default function Page() {
geometryVisibility={geometryVisibility} geometryVisibility={geometryVisibility}
allowGeometryEditing={false} allowGeometryEditing={false}
applyGeometryBindingFilter={true} applyGeometryBindingFilter={true}
onHoverFeatureChange={handleMapHoverChange} onFeatureClick={handleMapHoverChange}
highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries} focusFeatureCollection={activeEntityGeometries}
focusRequestKey={entityFocusToken} focusRequestKey={entityFocusToken}
focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }} focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
+76 -15
View File
@@ -13,7 +13,7 @@ import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync"; import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay"; import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
export type MapHoverPayload = { export type MapFeaturePayload = {
featureId: string | number; featureId: string | number;
feature: Feature | null; feature: Feature | null;
point: { x: number; y: number }; point: { x: number; y: number };
@@ -29,6 +29,7 @@ export type MapHandle = {
projection: string; projection: string;
} | null; } | null;
getMap: () => import("maplibre-gl").Map | null; getMap: () => import("maplibre-gl").Map | null;
setGlobeProjection: (isGlobe: boolean) => void;
}; };
type MapProps = { type MapProps = {
@@ -53,8 +54,7 @@ type MapProps = {
height?: CSSProperties["height"]; height?: CSSProperties["height"];
fitToDraftBounds?: boolean; fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null; fitBoundsKey?: string | number | null;
onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined; onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
@@ -62,6 +62,10 @@ type MapProps = {
onImageOverlayChange?: (overlay: MapImageOverlay) => void; onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
showViewportControls?: boolean; showViewportControls?: boolean;
isPreviewMode?: boolean;
onEnterPreview?: () => void;
onExitPreview?: () => void;
onPlayPreviewReplay?: () => void;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -83,8 +87,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
height = "100vh", height = "100vh",
fitToDraftBounds = false, fitToDraftBounds = false,
fitBoundsKey = null, fitBoundsKey = null,
onHoverFeatureChange, onFeatureClick,
highlightFeatures = null,
focusFeatureCollection = null, focusFeatureCollection = null,
focusRequestKey = null, focusRequestKey = null,
focusPadding, focusPadding,
@@ -92,6 +95,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onImageOverlayChange, onImageOverlayChange,
onBindGeometries, onBindGeometries,
showViewportControls = true, showViewportControls = true,
isPreviewMode = false,
onEnterPreview,
onExitPreview,
onPlayPreviewReplay,
}, ref) { }, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần. // Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
@@ -101,8 +108,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select. // Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
const onSetModeRef = useRef(onSetMode); const onSetModeRef = useRef(onSetMode);
// Ref callback hover mới nhất cho tooltip/panel ngoài map. // Ref callback click feature mới nhất cho tooltip/panel ngoài map.
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange); const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
// Ref callback create mới nhất khi drawing engine tạo feature. // Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature); const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback delete mới nhất khi editing engine xóa feature. // 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(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
@@ -153,7 +160,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getViewState, getViewState,
getMap: () => mapRef.current, 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. // Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const { const {
@@ -173,14 +183,13 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onDeleteRef, onDeleteRef,
onHideRef, onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onFeatureClickRef,
onBindGeometriesRef, onBindGeometriesRef,
}); });
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const { const {
applyRenderDraftToMap, applyRenderDraftToMap,
applyHighlightToMap,
applyImageOverlayToMap, applyImageOverlayToMap,
tryCenterToUserLocation, tryCenterToUserLocation,
} = useMapSync({ } = useMapSync({
@@ -194,7 +203,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
applyGeometryBindingFilter, applyGeometryBindingFilter,
fitToDraftBounds, fitToDraftBounds,
fitBoundsKey, fitBoundsKey,
highlightFeatures,
focusFeatureCollection, focusFeatureCollection,
focusRequestKey, focusRequestKey,
focusPadding, focusPadding,
@@ -208,7 +216,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const map = mapRef.current; const map = mapRef.current;
if (!map || !isMapLoaded) return; if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); setupMapLayers(map, backgroundVisibility);
applyImageOverlayToMap(); applyImageOverlayToMap();
setupMapInteractions(map); setupMapInteractions(map);
applyRenderDraftToMap(renderDraftRef.current); applyRenderDraftToMap(renderDraftRef.current);
@@ -363,9 +371,62 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
> >
{isGlobeProjection ? "Globe" : "Flat"} {isGlobeProjection ? "Globe" : "Flat"}
</span> </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" type="button"
onClick={() => handleZoomByStep(-0.8)} onClick={() => handleZoomByStep(-0.8)}
style={zoomButtonStyle} style={zoomButtonStyle}
@@ -194,41 +194,21 @@ export default function ReplayPreviewLayerPanel({
outline: "none", outline: "none",
}); });
const renderTooltipStyles = () => ( const renderStyles = () => (
<style dangerouslySetInnerHTML={{ __html: ` <style dangerouslySetInnerHTML={{ __html: `
.${buttonClassName} { .replay-preview-layer-panel::-webkit-scrollbar {
position: relative; display: none;
} }
.${buttonClassName}::after { .replay-preview-layer-panel {
content: attr(data-tooltip); scrollbar-width: none;
position: absolute; -ms-overflow-style: none;
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);
} }
`}} /> `}} />
); );
return ( return (
<div <div
className="replay-preview-layer-panel"
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -237,15 +217,16 @@ export default function ReplayPreviewLayerPanel({
border: "1px solid rgba(148, 163, 184, 0.22)", border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20, borderRadius: 20,
padding: "14px 10px", padding: "14px 10px",
width: 100, width: 58,
alignItems: "center", alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)", boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
maxHeight: "calc(100vh - 180px)", maxHeight: "calc(100vh - 180px)",
overflowY: "auto", overflowY: "auto",
overflowX: "hidden",
}} }}
> >
{renderTooltipStyles()} {renderStyles()}
{/* Background layers */} {/* Background layers */}
<div style={groupHeaderStyle}>Map</div> <div style={groupHeaderStyle}>Map</div>
@@ -256,8 +237,7 @@ export default function ReplayPreviewLayerPanel({
<button <button
key={layer.id} key={layer.id}
type="button" type="button"
className={buttonClassName} title={layer.label}
data-tooltip={layer.label}
onClick={() => onToggleBackground(layer.id)} onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400 style={getButtonStyles(active, "56, 189, 248")} // sky-400
> >
@@ -279,8 +259,7 @@ export default function ReplayPreviewLayerPanel({
<button <button
key={typeKey} key={typeKey}
type="button" type="button"
className={buttonClassName} title={label}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)} onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500 style={getButtonStyles(active, "249, 115, 22")} // orange-500
> >
@@ -302,8 +281,7 @@ export default function ReplayPreviewLayerPanel({
<button <button
key={typeKey} key={typeKey}
type="button" type="button"
className={buttonClassName} title={label}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)} onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400 style={getButtonStyles(active, "192, 132, 252")} // purple-400
> >
@@ -325,8 +303,7 @@ export default function ReplayPreviewLayerPanel({
<button <button
key={typeKey} key={typeKey}
type="button" type="button"
className={buttonClassName} title={label}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)} onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500 style={getButtonStyles(active, "245, 158, 11")} // amber-500
> >
@@ -352,7 +329,7 @@ const groupHeaderStyle: React.CSSProperties = {
const gridStyle: React.CSSProperties = { const gridStyle: React.CSSProperties = {
display: "grid", display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gridTemplateColumns: "1fr",
gap: 8, gap: 8,
width: "100%", width: "100%",
}; };
+32 -12
View File
@@ -150,18 +150,16 @@ export function filterDraftByBinding(
} }
const childIds = new Set<string>(); const childIds = new Set<string>();
const selectedChildren = new Set<string>(); const parentIds = new Set<string>();
const selectedParents = new Set<string>(); const featureParentMap = new Map<string, string>(); // childId -> parentId
for (const feature of fc.features) { for (const feature of fc.features) {
const featureId = String(feature.properties.id); const featureId = String(feature.properties.id);
const parentId = normalizeFeatureBoundWith(feature); const parentId = normalizeFeatureBoundWith(feature);
if (!parentId) continue; if (parentId) {
childIds.add(featureId); childIds.add(featureId);
if (selectedIds.has(parentId)) { parentIds.add(parentId);
selectedChildren.add(featureId); featureParentMap.set(featureId, parentId);
}
if (selectedIds.has(featureId)) {
selectedParents.add(parentId);
} }
} }
@@ -169,13 +167,35 @@ export function filterDraftByBinding(
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) }; 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 { return {
...fc, ...fc,
features: fc.features.filter((feature) => { features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id); const featureId = String(feature.properties.id);
if (selectedIds.has(featureId)) return true; const parentId = featureParentMap.get(featureId);
if (selectedChildren.has(featureId)) return true;
if (selectedParents.has(featureId)) return true; // 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); 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 { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils"; import { buildClientFeatureId } from "./mapUtils";
import { MapHoverPayload } from "../Map"; import type { MapFeaturePayload } from "../Map";
type EngineBinding = { type EngineBinding = {
cleanup: () => void; cleanup: () => void;
@@ -34,7 +34,7 @@ type UseMapInteractionProps = {
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>; onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => 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>; onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
}; };
@@ -51,7 +51,7 @@ export function useMapInteraction({
onDeleteRef, onDeleteRef,
onHideRef, onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onFeatureClickRef,
onBindGeometriesRef, onBindGeometriesRef,
}: UseMapInteractionProps) { }: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null); const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
@@ -69,7 +69,7 @@ export function useMapInteraction({
}, [mapRef, onUpdateRef]); }, [mapRef, onUpdateRef]);
useEffect(() => { useEffect(() => {
const allowsSelectionMode = mode === "select" || mode === "replay"; const allowsSelectionMode = mode === "select" || mode === "replay" || mode === "preview" || mode === "replay_preview";
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) { if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
// Clear the internal selection state of the select engine to stay in sync with React state // 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), (ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id), (id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current), () => 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( const cleanupPoint = initPoint(
@@ -277,7 +293,9 @@ export function useMapInteraction({
engineBindingsRef.current = { engineBindingsRef.current = {
draw: drawingEngine, draw: drawingEngine,
select: selectEngine, select: selectEngine,
preview: selectEngine,
replay: selectEngine, replay: selectEngine,
replay_preview: selectEngine,
"add-line": lineEngine, "add-line": lineEngine,
"add-path": pathEngine, "add-path": pathEngine,
"add-circle": circleEngine, "add-circle": circleEngine,
@@ -292,52 +310,6 @@ export function useMapInteraction({
drawingEngine.cleanup 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) { if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map); editingEngineRef.current?.bindEditEvents(map);
} }
+1 -59
View File
@@ -30,9 +30,7 @@ export function getBaseMapStyle(): maplibregl.StyleSpecification {
export function setupMapLayers( export function setupMapLayers(
map: maplibregl.Map, map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility, backgroundVisibility: BackgroundLayerVisibility
highlightFeatures: FeatureCollection | null,
applyHighlightToMap: (fc: FeatureCollection) => void
) { ) {
applyBackgroundLayerVisibility(map, backgroundVisibility); applyBackgroundLayerVisibility(map, backgroundVisibility);
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => { 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( async function replaceBackgroundLayersWithGoong(
+5 -35
View File
@@ -34,7 +34,6 @@ type UseMapSyncProps = {
applyGeometryBindingFilter: boolean; applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean; fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null; fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions; focusPadding?: number | maplibregl.PaddingOptions;
@@ -58,7 +57,6 @@ export function useMapSync({
applyGeometryBindingFilter, applyGeometryBindingFilter,
fitToDraftBounds, fitToDraftBounds,
fitBoundsKey, fitBoundsKey,
highlightFeatures,
focusFeatureCollection, focusFeatureCollection,
focusRequestKey, focusRequestKey,
focusPadding, focusPadding,
@@ -75,7 +73,6 @@ export function useMapSync({
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter); const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds); const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null); const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection); const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding); const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
@@ -90,7 +87,6 @@ export function useMapSync({
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]); useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]); useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]); useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]); useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]); useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]); useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
@@ -102,8 +98,7 @@ export function useMapSync({
const applyRenderDraftToMap = useCallback(( const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection, renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection, labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[], selectedIdsOverride?: (string | number)[]
highlightFeaturesOverride?: FeatureCollection | null
) => { ) => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@@ -122,12 +117,9 @@ export function useMapSync({
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc; const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current; const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
? highlightFeaturesOverride
: highlightFeaturesRef.current;
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal) ? filterDraftByBinding(renderFc, currentSelectedIds)
: renderFc; : renderFc;
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current); const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft); const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
@@ -157,15 +149,7 @@ export function useMapSync({
} }
}, [mapRef]); }, [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(() => { const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return; if (geolocationCenteredRef.current) return;
@@ -198,12 +182,7 @@ export function useMapSync({
applyBackgroundLayerVisibility(map, backgroundVisibility); applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]); }, [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(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@@ -212,7 +191,7 @@ export function useMapSync({
}, [imageOverlay, mapRef]); }, [imageOverlay, mapRef]);
useEffect(() => { useEffect(() => {
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures); applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
const editingId = editingEngineRef.current?.editingRef?.current?.id; const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) { if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = renderDraft.features.some((f) => f.properties.id === editingId); const stillExists = renderDraft.features.some((f) => f.properties.id === editingId);
@@ -228,7 +207,6 @@ export function useMapSync({
selectedFeatureIds, selectedFeatureIds,
applyGeometryBindingFilter, applyGeometryBindingFilter,
geometryVisibility, geometryVisibility,
highlightFeatures,
applyRenderDraftToMap, applyRenderDraftToMap,
editingEngineRef, editingEngineRef,
]); ]);
@@ -266,7 +244,6 @@ export function useMapSync({
return { return {
applyRenderDraftToMap, applyRenderDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation, tryCenterToUserLocation,
applyImageOverlayToMap: () => { applyImageOverlayToMap: () => {
const map = mapRef.current; 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"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import "react-quill-new/dist/quill.snow.css";
import type { Entity } from "@/uhm/api/entities"; import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis"; import type { Wiki } from "@/uhm/api/wikis";
@@ -238,34 +239,101 @@ export default function PublicWikiSidebar({
return ( return (
<div <div
style={{ width: `${width}px` }} style={{
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" 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 */} {/* Drag Handle on the left edge */}
<div <div
onPointerDown={handlePointerDown} 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" title="Kéo để chỉnh kích thước"
> >
{/* Visual drag line overlay */} {/* 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>
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800"> <div
<div className="flex items-start justify-between gap-3"> style={{
<div className="min-w-0"> borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400"> 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 Wiki
</div> </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"} {entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
</div> </div>
{entity?.description?.trim() ? ( {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()} {entity.description.trim()}
</div> </div>
) : null} ) : null}
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? ( {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()} {wiki.title.trim()}
</div> </div>
) : null} ) : null}
@@ -274,7 +342,22 @@ export default function PublicWikiSidebar({
<button <button
type="button" type="button"
onClick={onClose} 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" aria-label="Close wiki sidebar"
> >
x x
@@ -283,18 +366,44 @@ export default function PublicWikiSidebar({
</div> </div>
{toc.length ? ( {toc.length ? (
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800"> <div
<div className="flex gap-2 overflow-x-auto pb-1"> 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) => { {toc.slice(0, 8).map((item) => {
const isActive = effectiveActiveHeadingId === item.id; const isActive = effectiveActiveHeadingId === item.id;
return ( return (
<a <a
key={item.id} key={item.id}
href={`#${item.id}`} href={`#${item.id}`}
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive style={{
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300" flexShrink: 0,
: "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]" 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} {item.text}
</a> </a>
@@ -304,31 +413,74 @@ export default function PublicWikiSidebar({
</div> </div>
) : null} ) : 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 ? ( {isLoading ? (
<div className="space-y-3 px-4 py-4"> <div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" /> <div
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" /> style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" /> 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> </div>
) : error ? ( ) : 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} {error}
</div> </div>
) : wiki ? ( ) : wiki ? (
<div <div
ref={contentRootRef} 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 }} 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. Entity này chưa wiki liên kết.
</div> </div>
)} )}
</div> </div>
<style jsx global>{` <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 { .uhm-wiki-sidebar-view.ql-editor {
height: auto; height: auto;
overflow-y: visible; overflow-y: visible;
@@ -338,6 +490,7 @@ export default function PublicWikiSidebar({
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
color: #cbd5e1 !important;
} }
.uhm-wiki-sidebar-view.ql-editor p { .uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em; margin: 0 0 0.75em;
@@ -347,12 +500,14 @@ export default function PublicWikiSidebar({
font-size: 1.6em; font-size: 1.6em;
font-weight: 800; font-weight: 800;
line-height: 1.2; line-height: 1.2;
color: #f8fafc !important;
} }
.uhm-wiki-sidebar-view.ql-editor h2 { .uhm-wiki-sidebar-view.ql-editor h2 {
margin: 1.05em 0 0.55em; margin: 1.05em 0 0.55em;
font-size: 1.3em; font-size: 1.3em;
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.25;
color: #f8fafc !important;
} }
.uhm-wiki-sidebar-view.ql-editor h3, .uhm-wiki-sidebar-view.ql-editor h3,
.uhm-wiki-sidebar-view.ql-editor h4, .uhm-wiki-sidebar-view.ql-editor h4,
@@ -362,6 +517,7 @@ export default function PublicWikiSidebar({
font-size: 1.05em; font-size: 1.05em;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
color: #f8fafc !important;
} }
.uhm-wiki-sidebar-view.ql-editor ul, .uhm-wiki-sidebar-view.ql-editor ul,
.uhm-wiki-sidebar-view.ql-editor ol { .uhm-wiki-sidebar-view.ql-editor ol {
@@ -371,45 +527,53 @@ export default function PublicWikiSidebar({
.uhm-wiki-sidebar-view.ql-editor blockquote { .uhm-wiki-sidebar-view.ql-editor blockquote {
margin: 0 0 0.75em; margin: 0 0 0.75em;
padding-left: 12px; padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.6); border-left: 3px solid rgba(148, 163, 184, 0.4);
color: rgba(71, 85, 105, 1);
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
border-left-color: rgba(100, 116, 139, 0.6);
color: rgba(203, 213, 225, 0.95); color: rgba(203, 213, 225, 0.95);
} }
.uhm-wiki-sidebar-view.ql-editor pre { .uhm-wiki-sidebar-view.ql-editor pre {
margin: 0 0 0.75em; margin: 0 0 0.75em;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1); border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 10px; border-radius: 10px;
background: rgba(248, 250, 252, 1); background: rgba(15, 23, 42, 0.4);
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
color: #cbd5e1;
} }
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre { .uhm-wiki-sidebar-view.ql-editor img {
border-color: rgba(51, 65, 85, 1); max-width: 100%;
background: rgba(2, 6, 23, 0.4); height: auto;
} border-radius: 8px;
.uhm-wiki-sidebar-view.ql-editor img { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: block !important; }
max-width: 100% !important; .uhm-wiki-sidebar-view.ql-editor img[style*="float: left"],
height: auto !important; .uhm-wiki-sidebar-view.ql-editor img.ql-align-left {
border-radius: 8px; float: left !important;
float: none !important; margin: 4px 14px 14px 0px !important;
margin: 1.25em auto !important; display: inline !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); }
} .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 { .uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
} }
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) { .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__"]) { .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]):hover {
color: #60a5fa; color: #7dd3fc !important;
} }
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] { .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
cursor: default; 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:not([href]),
.uhm-wiki-sidebar-view.ql-editor a[href=""], .uhm-wiki-sidebar-view.ql-editor a[href=""],
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] { .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #dc2626; color: #f87171 !important;
}
: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;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor { .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 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ó - 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 - 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 ## 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. - Undo trong replay mode chỉ undo replay session, không undo main geometry.
- Play preview: - Play preview:
- Step selection chạy đúng thứ tự. - 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. - Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo.
## 8. Import GEO từ search ## 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. - Resize panel.
- Search query. - Search query.
- Pick/paste/remove image overlay trace. - 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 ## 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 - `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"`. 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 ### 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. Đâ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 ## 5. Snapshot state
Editor đang làm việc với các snapshot collection chính ngoài geometry: 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-line"
| "add-path" | "add-path"
| "add-circle" | "add-circle"
| "preview"
| "replay" | "replay"
| "replay_preview"; | "replay_preview";
+49 -10
View File
@@ -1,6 +1,12 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; 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. // Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect( export function initSelect(
map: maplibregl.Map, map: maplibregl.Map,
@@ -12,7 +18,8 @@ export function initSelect(
onSelectIds?: (ids: (string | number)[]) => void, onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void, onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean, 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 = [ const FEATURE_STATE_SOURCES = [
@@ -24,6 +31,8 @@ export function initSelect(
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
let contextMenu: HTMLDivElement | null = null; let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | 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. // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
function clearSelection(emit = true) { 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. // 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) { function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id; const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return; if (id === undefined || id === null) return false;
if (!additive) { if (!additive) {
clearSelection(); clearSelection();
@@ -52,17 +61,19 @@ export function initSelect(
setSelectionStateForId(idToRemove, false); setSelectionStateForId(idToRemove, false);
selectedIds.delete(idToRemove); selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds)); onSelectIds?.(Array.from(selectedIds));
return; return false;
} }
setSelectionStateForId(id, true); setSelectionStateForId(id, true);
selectedIds.add(id); selectedIds.add(id);
onSelectIds?.(Array.from(selectedIds)); onSelectIds?.(Array.from(selectedIds));
return true;
} }
// Chọn feature theo click trái, hỗ trợ additive bằng Alt. // Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) { 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; if (isEditSessionActive?.()) return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return; if (!selectableLayers.length) return;
@@ -73,22 +84,37 @@ export function initSelect(
if (!features.length) { if (!features.length) {
clearSelection(); clearSelection();
onFeatureClick?.(null);
return; return;
} }
const additive = !!e.originalEvent?.altKey; 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. // 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. // Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) { 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(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return; if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu e.preventDefault(); // block browser menu
if (getMode() === "replay") return; if (mode === "replay" || mode === "preview" || mode === "replay_preview") return;
if (isEditSessionActive?.()) return; if (isEditSessionActive?.()) return;
const features = map.queryRenderedFeatures(e.point, { 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. // Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) { function updateCursorFromPendingPoint() {
if (getMode() !== "select" && getMode() !== "replay") return; cursorTimer = null;
const mode = getMode();
if (mode !== "select" && mode !== "replay" && mode !== "preview" && mode !== "replay_preview") return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) { if (!selectableLayers.length) {
map.getCanvas().style.cursor = ""; map.getCanvas().style.cursor = "";
return; return;
} }
if (!pendingCursorPoint) return;
const features = map.queryRenderedFeatures(e.point, { const features = map.queryRenderedFeatures([pendingCursorPoint.x, pendingCursorPoint.y], {
layers: selectableLayers, layers: selectableLayers,
}); });
map.getCanvas().style.cursor = features.length ? "pointer" : ""; 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[] { function getSelectableLayers(): string[] {
const style = map.getStyle(); const style = map.getStyle();
if (!style || !style.layers) return []; if (!style || !style.layers) return [];
@@ -198,6 +233,10 @@ export function initSelect(
try { try {
map.off("click", onClick); map.off("click", onClick);
map.off("mousemove", onMove); map.off("mousemove", onMove);
if (cursorTimer !== null) {
window.clearTimeout(cursorTimer);
cursorTimer = null;
}
if (hasContextActions) { if (hasContextActions) {
map.off("contextmenu", onRightClick); map.off("contextmenu", onRightClick);
} }
@@ -9,5 +9,6 @@ export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: st
strokeColor: "#065f46", strokeColor: "#065f46",
dasharray: [4, 3], dasharray: [4, 3],
arrowOpacity: 0.76, arrowOpacity: 0.76,
showLine: false,
}); });
} }
@@ -9,5 +9,6 @@ export function getMilitaryRouteLayers(sourceId: string, pathArrowSourceId?: str
strokeColor: "#7f1d1d", strokeColor: "#7f1d1d",
width: { z1: 2.6, z4: 3.8, z6: 5 }, width: { z1: 2.6, z4: 3.8, z6: 5 },
arrowOpacity: 0.9, arrowOpacity: 0.9,
showLine: false,
}); });
} }
@@ -10,5 +10,6 @@ export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: stri
dasharray: [6, 3], dasharray: [6, 3],
opacity: 0.82, opacity: 0.82,
arrowOpacity: 0.68, arrowOpacity: 0.68,
showLine: false,
}); });
} }
@@ -9,5 +9,6 @@ export function getTradeRouteLayers(sourceId: string, pathArrowSourceId?: string
strokeColor: "#854d0e", strokeColor: "#854d0e",
dasharray: [5, 3], dasharray: [5, 3],
arrowOpacity: 0.78, arrowOpacity: 0.78,
showLine: false,
}); });
} }
@@ -22,6 +22,7 @@ type LineGeotypeStyle = {
arrowOpacity?: number; arrowOpacity?: number;
arrowOutlineColor?: string; arrowOutlineColor?: string;
arrowOutlineWidth?: ZoomStops; arrowOutlineWidth?: ZoomStops;
showLine?: boolean;
}; };
type PolygonGeotypeStyle = { type PolygonGeotypeStyle = {
@@ -64,7 +65,7 @@ export function buildLineGeotypeLayers(
paint: { paint: {
"line-color": statusColor(style.color), "line-color": statusColor(style.color),
"line-width": widthStops(style.width ?? DEFAULT_LINE_WIDTH), "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 } : {}), ...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
}, },
}; };
+10 -4
View File
@@ -45,6 +45,7 @@ type UseReplayPreviewOptions = {
selectedStageId: number | null; selectedStageId: number | null;
selectedStepIndex: number | null; selectedStepIndex: number | null;
onSelectStep: (stageId: number | null, stepIndex: number | null) => void; onSelectStep: (stageId: number | null, stepIndex: number | null) => void;
setMapProjection?: (type: "globe" | "mercator") => void;
}; };
export function useReplayPreview({ export function useReplayPreview({
@@ -57,6 +58,7 @@ export function useReplayPreview({
selectedStageId, selectedStageId,
selectedStepIndex, selectedStepIndex,
onSelectStep, onSelectStep,
setMapProjection,
}: UseReplayPreviewOptions) { }: UseReplayPreviewOptions) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [dialog, setDialog] = useState<DialogState | null>(null); const [dialog, setDialog] = useState<DialogState | null>(null);
@@ -172,9 +174,13 @@ export function useReplayPreview({
if (map) { if (map) {
mapActions.restore_label_visibility(map, baseline.labelVisibility); mapActions.restore_label_visibility(map, baseline.labelVisibility);
if (baseline.mapViewState) { if (baseline.mapViewState) {
map.setProjection({ if (setMapProjection) {
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator", setMapProjection(baseline.mapViewState.projection === "globe" ? "globe" : "mercator");
}); } else {
map.setProjection({
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator",
});
}
mapActions.set_camera_view(map, { mapActions.set_camera_view(map, {
center: baseline.mapViewState.center, center: baseline.mapViewState.center,
zoom: baseline.mapViewState.zoom, zoom: baseline.mapViewState.zoom,
@@ -184,7 +190,7 @@ export function useReplayPreview({
}); });
} }
} }
}, [getMapInstance, resetPresentation]); }, [getMapInstance, resetPresentation, setMapProjection]);
const resetPreview = useCallback(() => { const resetPreview = useCallback(() => {
runIdRef.current += 1; runIdRef.current += 1;