feat: implement comprehensive map editing system with advanced geometry tools, replay management, and project session state modules.
Build and Release / release (push) Successful in 55s

This commit is contained in:
taDuc
2026-06-11 22:05:18 +07:00
parent 0d6599015b
commit 5a8dfc4b50
180 changed files with 43408 additions and 119 deletions
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { memo, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { ProjectPanel } from "./editor/ProjectPanel";
import { ToolsPanel } from "./editor/ToolsPanel";
import { CommitPanel } from "./editor/CommitPanel";
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
import { UndoListPanel } from "./editor/UndoListPanel";
import { SubmitModal } from "./editor/SubmitModal";
import ImageOverlayPanel from "./editor/ImageOverlayPanel";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
onSubmit: (content: string) => void;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
sectionTitle: string;
projectStatus: string;
commitTitle: string;
onCommitTitleChange: (title: string) => void;
commitCount: number;
hasHeadCommit: boolean;
headCommitId: string | null;
latestCommitLabel: string | null;
commits: Array<{
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
}>;
changesCount: number;
undoStack: UndoAction[];
width?: number;
imageOverlay: MapImageOverlay | null;
onPickImageOverlay: (file: File | null) => void;
onPasteImageOverlay: () => void;
imageOverlayKeyboardEnabled: boolean;
onImageOverlayKeyboardEnabledChange: (enabled: boolean) => void;
onImageOverlayOpacityChange: (opacity: number) => void;
onRemoveImageOverlay: () => void;
};
function Editor({
mode,
setMode,
entityStatus,
onUndo,
onCommit,
onSubmit,
onRestoreCommit,
isSaving,
isSubmitting,
sectionTitle,
projectStatus,
commitTitle,
onCommitTitleChange,
commitCount,
hasHeadCommit,
headCommitId,
latestCommitLabel,
commits,
changesCount,
undoStack,
width = 350,
imageOverlay,
onPickImageOverlay,
onPasteImageOverlay,
imageOverlayKeyboardEnabled,
onImageOverlayKeyboardEnabledChange,
onImageOverlayOpacityChange,
onRemoveImageOverlay,
}: Props) {
// State đóng/mở modal submit project.
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
// State nội dung submit gửi lên backend khi user xác nhận.
const [submitContent, setSubmitContent] = useState("");
// Mở modal submit với nội dung sạch cho lần submit mới.
const handleOpenSubmitModal = () => {
setSubmitContent("");
setIsSubmitModalOpen(true);
};
// Xác nhận submit: đóng modal trước rồi chuyển content cho command cha.
const handleConfirmSubmit = () => {
setIsSubmitModalOpen(false);
onSubmit(submitContent);
};
// Hủy submit mà không thay đổi draft/commit.
const handleCancelSubmit = () => {
setIsSubmitModalOpen(false);
};
return (
<div
className="no-scrollbar"
style={{
width,
height: "100vh",
overflowY: "auto",
background: "#0b1220",
color: "white",
padding: "12px 12px 20px",
borderRight: "1px solid #1f2937",
}}
>
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
<ProjectPanel
sectionTitle={sectionTitle}
projectStatus={projectStatus}
commitCount={commitCount}
latestCommitLabel={latestCommitLabel}
/>
<div style={{ marginTop: 10 }}>
<ImageOverlayPanel
overlay={imageOverlay}
onPickImage={onPickImageOverlay}
onPasteImage={onPasteImageOverlay}
keyboardEnabled={imageOverlayKeyboardEnabled}
onKeyboardEnabledChange={onImageOverlayKeyboardEnabledChange}
onOpacityChange={onImageOverlayOpacityChange}
onRemove={onRemoveImageOverlay}
/>
</div>
<ToolsPanel
mode={mode}
setMode={setMode}
onUndo={onUndo}
/>
{entityStatus ? (
<div
style={{
marginTop: 10,
padding: "10px",
background: "#111827",
borderRadius: 8,
border: "1px solid #7f1d1d",
color: "#fecaca",
fontSize: 12,
overflowWrap: "anywhere",
}}
>
{entityStatus}
</div>
) : null}
</div>
<CommitPanel
commitTitle={commitTitle}
onCommitTitleChange={onCommitTitleChange}
isSaving={isSaving}
isSubmitting={isSubmitting}
changesCount={changesCount}
onCommit={onCommit}
hasHeadCommit={hasHeadCommit}
handleOpenSubmitModal={handleOpenSubmitModal}
/>
<CommitHistoryPanel
commits={commits}
headCommitId={headCommitId}
onRestoreCommit={onRestoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
/>
<UndoListPanel undoStack={undoStack} />
<SubmitModal
isSubmitModalOpen={isSubmitModalOpen}
submitContent={submitContent}
setSubmitContent={setSubmitContent}
handleCancelSubmit={handleCancelSubmit}
handleConfirmSubmit={handleConfirmSubmit}
/>
</div>
);
}
export default memo(Editor);
+699
View File
@@ -0,0 +1,699 @@
"use client";
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, memo } from "react";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { useMapInstance } from "./map/useMapInstance";
import { setupMapLayers } from "./map/useMapLayers";
import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
import { useMapHoverPopup, type MapHoverPopupContent } from "./map/useMapHoverPopup";
export type MapFeaturePayload = {
featureId: string | number;
feature: Feature | null;
point: { x: number; y: number };
lngLat: { lng: number; lat: number };
};
export type MapHandle = {
getViewState: () => {
center: { lng: number; lat: number };
zoom: number;
pitch: number;
bearing: number;
projection: string;
} | null;
getMap: () => import("maplibre-gl").Map | null;
setGlobeProjection: (isGlobe: boolean) => void;
};
type MapProps = {
mode: EditorMode;
// FeatureCollection that should actually be rendered/interacted with on the map.
// Callers should apply timeline/replay filters before passing it here.
renderDraft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
// Label lookup context only. It may include non-rendered geometries for entity label resolution.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onAddFeatureToProject?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
applyGeometryBindingFilter?: boolean;
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
onHoverFeatureChange?: (feature: Feature | null) => void;
allowFeatureSelection?: boolean;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
localFeatureIds?: (string | number)[];
showViewportControls?: boolean;
isPreviewMode?: boolean;
onEnterPreview?: () => void;
onExitPreview?: () => void;
onPlayPreviewReplay?: () => void;
viewMode?: "local" | "global";
onViewModeChange?: (mode: "local" | "global") => void;
onLoad?: () => void;
};
const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
mode,
onSetMode,
renderDraft,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
onSelectFeatureIds,
labelContextDraft,
labelTimelineYear,
onCreateFeature,
onAddFeatureToProject,
onDeleteFeature,
onHideFeature,
onUpdateFeature,
allowGeometryEditing = true,
applyGeometryBindingFilter = true,
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
onHoverFeatureChange,
allowFeatureSelection = true,
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
imageOverlay = null,
onImageOverlayChange,
onBindGeometries,
localFeatureIds,
showViewportControls = true,
isPreviewMode = false,
onEnterPreview,
onExitPreview,
onPlayPreviewReplay,
viewMode = "local",
onViewModeChange,
onLoad,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
// Ref callback select feature mới nhất cho event click trên 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 click feature mới nhất cho tooltip/panel ngoài map.
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
const getHoverPopupContentRef = useRef<MapProps["getHoverPopupContent"]>(getHoverPopupContent);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback add geometry global vào project mới nhất cho context menu select.
const onAddFeatureToProjectRef = useRef<MapProps["onAddFeatureToProject"]>(onAddFeatureToProject);
// Ref callback delete mới nhất khi editing engine xóa feature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
const onHideRef = useRef<MapProps["onHideFeature"]>(onHideFeature);
// Ref callback update mới nhất khi editing engine đổi geometry.
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
// Ref giữ overlay mới nhất cho right-drag controls.
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
// Ref callback bind geometry mới nhất để interaction không stale.
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
// Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature.
const localFeatureIdsRef = useRef<MapProps["localFeatureIds"]>(localFeatureIds);
modeRef.current = mode;
renderDraftRef.current = renderDraft;
onSelectFeatureIdsRef.current = onSelectFeatureIds;
onSetModeRef.current = onSetMode;
onFeatureClickRef.current = onFeatureClick;
getHoverPopupContentRef.current = getHoverPopupContent;
onHoverFeatureChangeRef.current = onHoverFeatureChange;
onCreateRef.current = onCreateFeature;
onAddFeatureToProjectRef.current = onAddFeatureToProject;
onDeleteRef.current = onDeleteFeature;
onHideRef.current = onHideFeature;
onUpdateRef.current = onUpdateFeature;
imageOverlayRef.current = imageOverlay;
onImageOverlayChangeRef.current = onImageOverlayChange;
onBindGeometriesRef.current = onBindGeometries;
localFeatureIdsRef.current = localFeatureIds;
useEffect(() => {
// Dynamically import MapLibre CSS to prevent it from blocking initial layout bundle CSS load.
import("maplibre-gl/dist/maplibre-gl.css");
}, []);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
mapRef,
containerRef,
fatalInitError,
zoomLevel,
zoomBounds,
isGlobeProjection,
setIsGlobeProjection,
isMapLoaded,
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState,
} = useMapInstance();
// Public API cho parent đọc map instance/view state mà không expose implementation nội bộ.
useImperativeHandle(ref, () => ({
getViewState,
getMap: () => mapRef.current,
setGlobeProjection: (isGlobe: boolean) => {
setIsGlobeProjection(isGlobe);
},
}), [getViewState, mapRef, setIsGlobeProjection]);
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const {
editingEngineRef,
setupMapInteractions,
cleanupMapInteractions,
} = useMapInteraction({
mapRef,
mode,
modeRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onHideRef,
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyRenderDraftToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
mapRef,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
});
useMapHoverPopup({
mapRef,
enabled: hoverPopupEnabled,
renderDraftRef,
getContentRef: getHoverPopupContentRef,
onHoverFeatureChangeRef,
});
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility);
applyImageOverlayToMap();
setupMapInteractions(map);
applyRenderDraftToMap(renderDraftRef.current);
tryCenterToUserLocation();
return () => {
cleanupMapInteractions();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapLoaded]);
useEffect(() => {
const map = mapRef.current;
if (map && isMapLoaded) {
// Trigger resize after a short delay to allow layout to settle
setTimeout(() => map.resize(), 100);
}
}, [mode, isMapLoaded, mapRef]);
useEffect(() => {
if (isMapLoaded && onLoad) {
onLoad();
}
}, [isMapLoaded, onLoad]);
const hasImageOverlay = Boolean(imageOverlay);
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded || !hasImageOverlay) return;
return bindImageOverlayInteractions(
map,
() => imageOverlayRef.current,
(nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay)
);
}, [hasImageOverlay, isMapLoaded, mapRef]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
position: "relative",
zIndex: 1,
backgroundColor: "transparent"
}}
/>
{fatalInitError ? (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 50,
display: "grid",
placeItems: "center",
padding: "24px",
background: "rgba(2, 6, 23, 0.78)",
color: "#e2e8f0",
}}
>
<div
style={{
maxWidth: "680px",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "12px",
background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
}}
>
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
Không khởi tạo đưc bản đ
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
{fatalInitError}
</div>
</div>
</div>
) : null}
{showViewportControls ? (
<div
style={{
position: "absolute",
top: "10px",
left: "16px",
right: "16px",
zIndex: 12,
pointerEvents: "none",
}}
>
<div
style={{
width: "fit-content",
maxWidth: "95%",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "50px",
padding: "8px 16px",
color: "#f8fafc",
boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
pointerEvents: "auto",
}}
>
<style dangerouslySetInnerHTML={{ __html: `
.premium-zoom-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: grid;
place-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.premium-zoom-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.premium-zoom-btn:active {
background: rgba(16, 185, 129, 0.25);
border-color: #10b981;
}
.premium-zoom-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
min-width: 80px;
height: 24px;
background: transparent;
cursor: pointer;
outline: none;
}
@media (max-width: 768px) {
.premium-zoom-slider {
display: none !important;
}
}
.premium-zoom-slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.premium-zoom-slider:hover::-webkit-slider-runnable-track {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.premium-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -6px;
height: 18px;
width: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
border: 1.5px solid #ffffff;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4), 0 3px 6px rgba(0, 0, 0, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
}
.premium-zoom-slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.6), 0 5px 10px rgba(0, 0, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.5);
}
.premium-toggle-track {
width: 38px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex: 0 0 auto;
}
.premium-toggle-track.active {
background: rgba(52, 211, 153, 0.35);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.premium-toggle-thumb {
position: absolute;
top: 1.5px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.premium-toggle-track.active .premium-toggle-thumb {
left: 19px;
background: #34d399;
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.25);
}
`}} />
<label
title={
isGlobeProjection
? "Đang ở chế độ hình cầu"
: "Đang ở chế độ bản đồ phẳng"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "0 2px",
userSelect: "none",
cursor: "pointer",
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={isGlobeProjection}
onChange={(e) => setIsGlobeProjection(e.target.checked)}
aria-label="Chuyển chế độ hiển thị hình cầu"
style={{ display: "none" }}
/>
<div className={`premium-toggle-track ${isGlobeProjection ? "active" : ""}`}>
<span className="premium-toggle-thumb" />
</div>
<span
style={{
fontSize: "12px",
color: isGlobeProjection ? "#ffffff" : "#94a3b8",
fontWeight: 700,
minWidth: "40px",
transition: "color 0.25s ease",
}}
className="hidden sm:block"
>
{isGlobeProjection ? "Cầu" : "Phẳng"}
</span>
</label>
{onViewModeChange ? (
<div style={{ display: "flex", background: "rgba(255, 255, 255, 0.08)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(255, 255, 255, 0.15)", gap: "2px", flexShrink: 0 }}>
<button
type="button"
onClick={() => onViewModeChange("local")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "local" ? "#2563eb" : "transparent",
color: viewMode === "local" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
CỤC BỘ
</button>
<button
type="button"
onClick={() => onViewModeChange("global")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "global" ? "#2563eb" : "transparent",
color: viewMode === "global" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
TOÀN CỤC
</button>
</div>
) : null}
{onEnterPreview || onExitPreview ? (
<button
type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
className="premium-zoom-btn"
style={{
width: "auto",
minWidth: "76px",
padding: "0 12px",
background: isPreviewMode ? "rgba(51, 65, 85, 0.6)" : "rgba(16, 185, 129, 0.25)",
borderColor: isPreviewMode ? "rgba(255,255,255,0.15)" : "#10b981",
color: isPreviewMode ? "#ffffff" : "#34d399",
flexShrink: 0,
}}
aria-label={isPreviewMode ? "Thoát xem trước" : "Vào chế độ xem trước"}
title={isPreviewMode ? "Thoát xem trước" : "Xem như người dùng"}
>
{isPreviewMode ? "Trình sửa" : "Xem trước"}
</button>
) : null}
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
className="premium-zoom-btn"
style={{
width: "auto",
minWidth: "64px",
padding: "0 12px",
display: "inline-flex",
gap: "7px",
background: "rgba(56, 189, 248, 0.15)",
borderColor: "rgba(56, 189, 248, 0.4)",
color: "#38bdf8",
fontSize: "13px",
flexShrink: 0,
}}
aria-label="Phát diễn biến đã chọn"
title="Phát diễn biến của hình đang chọn"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
borderLeft: "8px solid currentColor",
}}
/>
Phát
</button>
) : null}
<div className="hidden sm:flex items-center gap-[10px] flex-shrink-0">
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Thu nhỏ bản đồ"
>
-
</button>
<input
type="range"
min={zoomBounds.min}
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
className="premium-zoom-slider"
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
aria-label="Mức thu phóng bản đồ"
/>
<button
type="button"
onClick={() => handleZoomByStep(0.8)}
className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Phóng to bản đồ"
>
+
</button>
<div
style={{
minWidth: "48px",
textAlign: "right",
fontSize: "12px",
fontWeight: 700,
color: "#94a3b8",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
}}
>
{zoomLevel.toFixed(1)}x
</div>
</div>
</div>
</div>
) : null}
</div>
);
}));
export default Map;
const zoomButtonStyle: React.CSSProperties = {
width: "28px",
height: "28px",
borderRadius: "999px",
border: "1px solid #334155",
background: "#1e293b",
color: "#f8fafc",
fontSize: "18px",
lineHeight: "1",
cursor: "pointer",
display: "grid",
placeItems: "center",
};
@@ -0,0 +1,181 @@
"use client";
import { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
topContent?: ReactNode;
width?: number;
};
export default function BackgroundLayersPanel({
topContent,
width = 240,
}: Props) {
const {
visibility,
setBackgroundVisibility,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
visibility: state.backgroundVisibility,
setBackgroundVisibility: state.setBackgroundVisibility,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const updateBackgroundVisibility = (
updater: (prev: typeof visibility) => typeof visibility
) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAll = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAll = () => {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
return (
<aside
className="no-scrollbar"
style={{
width,
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
padding: "12px",
height: "100vh",
overflowY: "auto",
}}
>
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const on = Boolean(visibility[layer.id]);
return (
<button
key={layer.id}
type="button"
onClick={() => handleToggleLayer(layer.id)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
}}
title={on ? "On" : "Off"}
>
{layer.label}
</button>
);
})}
</div>
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
<button
type="button"
onClick={handleShowAll}
style={secondaryButtonStyle}
>
Show all
</button>
<button
type="button"
onClick={handleHideAll}
style={secondaryButtonStyle}
>
Hide all
</button>
</div>
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
</aside>
);
}
const secondaryButtonStyle = {
border: "1px solid #334155",
borderRadius: 6,
background: "#0b1220",
color: "#cbd5e1",
cursor: "pointer",
fontSize: 12,
fontWeight: 700,
padding: "6px 8px",
} as const;
@@ -0,0 +1,89 @@
import { Panel } from "./Panel";
type Commit = {
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
};
type CommitHistoryPanelProps = {
commits: Commit[];
headCommitId: string | null;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
};
export function CommitHistoryPanel({
commits,
headCommitId,
onRestoreCommit,
isSaving,
isSubmitting,
}: CommitHistoryPanelProps) {
const formatCommitTitle = (commit: Commit) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
return (
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{commits.slice(0, 8).map((commit) => {
const isHead = Boolean(headCommitId && commit.id === headCommitId);
return (
<li
key={commit.id}
style={{
padding: "8px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
display: "flex",
flexDirection: "row"
}}
>
<div style={{flex:1}}>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 750,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: 3, color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
</div>
<button
style={{
marginTop: 6,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #334155",
background: isHead ? "#0b1220" : "#334155",
color: "white",
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
opacity: isHead ? 0.65 : 1,
fontWeight: 800,
fontSize: 12,
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting || isHead}
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
>
Restore
</button>
</li>
);
})}
</ul>
)}
</Panel>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Panel } from "./Panel";
type CommitPanelProps = {
commitTitle: string;
onCommitTitleChange: (title: string) => void;
isSaving: boolean;
isSubmitting: boolean;
changesCount: number;
onCommit: () => void;
hasHeadCommit: boolean;
handleOpenSubmitModal: () => void;
};
export function CommitPanel({
commitTitle,
onCommitTitleChange,
isSaving,
isSubmitting,
changesCount,
onCommit,
hasHeadCommit,
handleOpenSubmitModal,
}: CommitPanelProps) {
const primaryButtonStyle = {
width: "100%",
padding: "8px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontWeight: 850,
fontSize: 12,
} as const;
const textInputStyle = {
width: "100%",
marginTop: 0,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
} as const;
return (
<Panel title="Commit" defaultOpen>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Edit Summary (Commit Title)"
disabled={isSaving || isSubmitting}
style={textInputStyle}
/>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
opacity: changesCount <= 0 ? 0.75 : 1,
}}
onClick={onCommit}
disabled={isSaving || isSubmitting || changesCount <= 0}
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
>
Commit ({changesCount})
</button>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={handleOpenSubmitModal}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
</Panel>
);
}
@@ -0,0 +1,281 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import type { Wiki } from "@/uhm/api/wikis";
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
type EditorSearchResultsProps = {
searchKind: UnifiedSearchKind;
onSearchKindChange: (kind: UnifiedSearchKind) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onLocalSearchQueryChange: (query: string) => void;
searchQueryDraft: string;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
onAddEntityRefToProject: (entity: Entity) => void;
wikiSearchResults: Wiki[];
isWikiSearching: boolean;
onAddWikiRefToProject: (wiki: Wiki) => void;
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
};
export function EditorSearchResults({
searchKind,
onSearchKindChange,
searchQuery,
onSearchQueryChange,
onLocalSearchQueryChange,
searchQueryDraft,
entitySearchResults,
isEntitySearchLoading,
onAddEntityRefToProject,
wikiSearchResults,
isWikiSearching,
onAddWikiRefToProject,
geoSearchResults,
isGeoSearching,
onImportGeoFromSearch,
}: EditorSearchResultsProps) {
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
const hasQuery = searchQueryDraft.trim().length > 0;
return (
<>
<UnifiedSearchBar
kind={searchKind}
onKindChange={onSearchKindChange}
query={searchQuery}
onQueryChange={onSearchQueryChange}
onLocalQueryChange={onLocalSearchQueryChange}
/>
{searchKind === "entity" && hasQuery ? (
<SearchBox
title="Entity Results"
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
>
{entitySearchResults.slice(0, 8).map((entity) => (
<ResultRow
key={entity.id}
title={entity.name}
subtitle={entity.id}
actionLabel="Add"
actionTitle="Add entity ref to project snapshot"
onAction={() => onAddEntityRefToProject(entity)}
/>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "wiki" && hasQuery ? (
<SearchBox
title="Wiki Results"
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
>
{wikiSearchResults.slice(0, 8).map((wiki) => (
<ResultRow
key={wiki.id}
title={(wiki.title || "").trim() || "Untitled wiki"}
subtitle={wiki.id}
actionLabel="Add"
actionTitle="Add wiki ref to project snapshot"
onAction={() => onAddWikiRefToProject(wiki)}
/>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "geo" && hasQuery ? (
<SearchBox
title="Geo Results"
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
>
{geoSearchResults.slice(0, 6).map((item) => (
<GeoResultGroup
key={item.entity_id}
item={item}
onImportGeoFromSearch={onImportGeoFromSearch}
/>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
</>
);
}
function SearchBox({
title,
status,
children,
}: {
title: string;
status: string;
children: ReactNode;
}) {
return (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
</div>
);
}
function ResultRow({
title,
subtitle,
actionLabel,
actionTitle,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
actionTitle: string;
onAction: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{subtitle}
</div>
</div>
<button
type="button"
onClick={onAction}
style={actionButtonStyle}
title={actionTitle}
>
{actionLabel}
</button>
</div>
);
}
function GeoResultGroup({
item,
onImportGeoFromSearch,
}: {
item: EntityGeometriesSearchItem;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
}) {
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
return (
<div
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{geometries.length} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onImportGeoFromSearch(item, geo)}
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
)}
</div>
);
}
function EmptyResult() {
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
}
const actionButtonStyle: CSSProperties = {
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
};
@@ -0,0 +1,480 @@
"use client";
import { useMemo, useState, memo } from "react";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type EntityChoice = { id: string; name: string; isNew?: boolean };
type WikiChoice = { id: string; title: string; isNew?: boolean };
type BindingRow = {
entityId: string;
entityName: string;
entityIsNew: boolean;
wikiId: string;
wikiTitle: string;
wikiIsNew: boolean;
linkIsNew: boolean;
};
type Props = {
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
};
function wikiTitle(w: WikiSnapshot): string {
const t = String(w.title || "").trim();
return t.length ? t : "Untitled wiki";
}
function EntityWikiBindingsPanel({ setLinks }: Props) {
const {
entityCatalog,
snapshotEntityRows,
wikis,
links,
} = useEditorStore(
useShallow((state) => ({
entityCatalog: state.entityCatalog,
snapshotEntityRows: state.snapshotEntityRows,
wikis: state.snapshotWikis,
links: state.snapshotEntityWikiLinks,
}))
);
const [activeEntityId, setActiveEntityId] = useState<string>("");
const [activeWikiId, setActiveWikiId] = useState<string>("");
const [collapsed, setCollapsed] = useState(false);
const wikiChoices: WikiChoice[] = useMemo(
() =>
(wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => ({
id: w.id,
title: wikiTitle(w),
isNew: w.source === "inline" && w.operation === "create",
})),
[wikis]
);
const entityChoices = useMemo<EntityChoice[]>(() => {
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
visibleSnapshotEntityRows.set(id, {
id,
name: String(ref?.name || id),
isNew: ref?.source === "inline" && ref?.operation === "create",
});
}
const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => {
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
return {
id: entity.id,
name: String(found?.name || entity.name || entity.id),
isNew: entity.isNew,
};
});
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [entityCatalog, snapshotEntityRows]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
for (const l of links || []) {
if (!l || l.entity_id !== activeEntityId) continue;
if (l.operation === "delete") continue;
set.add(l.wiki_id);
}
return set;
}, [activeEntityId, links]);
const activeBindingRows = useMemo<BindingRow[]>(() => {
const byKey = new Map<string, EntityWikiLinkSnapshot>();
for (const link of links || []) {
const entityId = String(link?.entity_id || "").trim();
const wikiId = String(link?.wiki_id || "").trim();
if (!entityId || !wikiId) continue;
if (link.operation === "delete") continue;
byKey.set(`${entityId}::${wikiId}`, link);
}
const rows = Array.from(byKey.values()).map((link) => {
const entityId = String(link.entity_id);
const wikiId = String(link.wiki_id);
const entity = entityChoices.find((item) => item.id === entityId) || null;
const wiki = wikiChoices.find((item) => item.id === wikiId) || null;
return {
entityId,
entityName: entity?.name || entityId,
entityIsNew: Boolean(entity?.isNew),
wikiId,
wikiTitle: wiki?.title || wikiId,
wikiIsNew: Boolean(wiki?.isNew),
linkIsNew: link.operation === "binding",
};
});
rows.sort((a, b) => {
if (a.linkIsNew !== b.linkIsNew) return a.linkIsNew ? -1 : 1;
const entityCompare = a.entityName.localeCompare(b.entityName);
if (entityCompare !== 0) return entityCompare;
return a.wikiTitle.localeCompare(b.wikiTitle);
});
return rows;
}, [entityChoices, links, wikiChoices]);
const toggle = (wikiId: string) => {
if (!activeEntityId) return;
const id = String(wikiId || "").trim();
if (!id) return;
setLinks((prev) => {
const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
// If link exists (reference/binding), unlink by removing the row entirely.
if (idx >= 0 && prev[idx]?.operation !== "delete") {
return prev.filter((_, i) => i !== idx);
}
// If link doesn't exist, add as a new binding (create for relation).
return [
...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)),
{ entity_id: activeEntityId, wiki_id: id, operation: "binding" },
];
});
};
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
const activeEntityChoice = activeEntityId ? entityChoices.find((e) => e.id === activeEntityId) || null : null;
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity Wiki</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{activeBindingRows.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Entity Wiki" : "Thu gon panel Entity Wiki"}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : (
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
<select
value={activeEntityId}
onChange={(e) => setActiveEntityId(e.target.value)}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
>
<option value="">Select entity</option>
{entityChoices.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
{activeEntityId ? (
<ActiveSelectionLabel
label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/>
) : null}
</div>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
<div style={{ display: "grid", gap: "8px" }}>
<select
value={activeWikiId}
onChange={(e) => setActiveWikiId(e.target.value)}
disabled={wikiChoices.length === 0}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
opacity: wikiChoices.length === 0 ? 0.7 : 1,
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
}}
>
<option value="">
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
</option>
{wikiChoices.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{activeWikiChoice ? (
<ActiveSelectionLabel
label={activeWikiChoice.title}
id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/>
) : null}
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : (
<>
<button
type="button"
disabled={!activeEntityId || !activeWikiId}
onClick={() => toggle(activeWikiId)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
background: activeWikiLinked ? "#334155" : "#16a34a",
color: "white",
fontWeight: 800,
fontSize: 12,
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
}}
>
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
</button>
{activeWikiChoice ? (
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
{activeWikiChoice.id}
</div>
) : null}
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
key={id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "#111827",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
title={id}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</span>
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</div>
</div>
<button
type="button"
onClick={() => toggle(id)}
style={{
border: "none",
background: "#0b1220",
color: "#fecaca",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 800,
flex: "0 0 auto",
}}
>
Unlink
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
)}
</>
)}
</div>
</div>
<div
style={{
borderTop: "1px solid #1f2937",
paddingTop: 8,
display: "grid",
gap: 6,
}}
>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
All bindings ({activeBindingRows.length})
</div>
{activeBindingRows.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
{activeBindingRows.map((row) => (
<div
key={`${row.entityId}::${row.wikiId}`}
style={{
padding: 8,
borderRadius: 6,
border: row.linkIsNew ? "1px solid rgba(45, 212, 191, 0.55)" : "1px solid #1f2937",
background: row.linkIsNew ? "rgba(20, 184, 166, 0.12)" : "#111827",
display: "grid",
gap: 5,
}}
title={`${row.entityId}${row.wikiId}`}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
fontWeight: 800,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityName}
</span>
{row.entityIsNew ? <NewBadge title="Entity mới trong phiên này" /> : null}
{row.linkIsNew ? <NewBadge title="Binding mới trong phiên này" /> : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#93c5fd", fontSize: 11, flex: "0 0 auto" }}>Wiki</span>
<span
style={{
color: "#cbd5e1",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.wikiTitle}
</span>
{row.wikiIsNew ? <NewBadge title="Wiki mới trong phiên này" /> : null}
</div>
<div
style={{
color: "#64748b",
fontSize: 11,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityId} {row.wikiId}
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No entity-wiki binding yet.</div>
)}
</div>
</div>
)}
</div>
);
}
function ActiveSelectionLabel({
label,
id,
isNew,
}: {
label: string;
id: string;
isNew?: boolean;
}) {
return (
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#cbd5e1", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</span>
{isNew ? <NewBadge /> : null}
</div>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export default memo(EntityWikiBindingsPanel);
@@ -0,0 +1,690 @@
"use client";
import { useMemo, useState, memo, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: unknown;
time_end?: unknown;
isTimelineVisible?: boolean;
isOrphan?: boolean;
timeStatus?: GeometryTimeStatus;
timelineStatus?: GeometryTimelineStatus;
isNew?: boolean;
};
type GeometryTimeStatus = "missing" | "partial" | "complete";
type GeometryTimelineStatus = "off" | "visible" | "filteredOut";
type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "timeStatus" | "timelineStatus" | "isNew">> & {
time_start: number | null;
time_end: number | null;
isTimelineVisible: boolean;
};
type Props = {
geometries: GeometryChoice[];
selectedGeometryId?: string | null;
selectedGeometryChildIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void;
};
function GeometryBindingPanel({
geometries,
selectedGeometryId,
selectedGeometryChildIds,
onToggleBindGeometryForSelectedGeometry,
onFocusGeometry,
}: Props) {
const {
selectedFeatureIds,
statusText,
bindingFilterEnabled,
setGeometryBindingFilterEnabled,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const effectiveSelectedGeometryId =
selectedGeometryId ??
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
const canBindToggle =
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false);
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: normalizeTimelineYearValue(g.time_start),
time_end: normalizeTimelineYearValue(g.time_end),
isTimelineVisible: Boolean(g.isTimelineVisible),
isOrphan: Boolean(g.isOrphan),
timeStatus: resolveTimeStatus(g),
timelineStatus: resolveTimelineStatus(g),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned;
}, [geometries]);
const childSet = useMemo(() => new Set(selectedGeometryChildIds || []), [selectedGeometryChildIds]);
const selectedGeometry = useMemo(() => {
if (!effectiveSelectedGeometryId) return null;
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
}, [effectiveSelectedGeometryId, rows]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== effectiveSelectedGeometryId)
.sort((a, b) => {
const aBound = childSet.has(a.id);
const bBound = childSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [childSet, effectiveSelectedGeometryId, rows]);
const summary = useMemo(() => {
let orphan = 0;
let missingTime = 0;
let partialTime = 0;
let filteredOut = 0;
let hidden = 0;
for (const row of rows) {
if (row.isOrphan) orphan += 1;
if (row.timeStatus === "missing") missingTime += 1;
if (row.timeStatus === "partial") partialTime += 1;
if (row.timelineStatus === "filteredOut") filteredOut += 1;
if (geometryVisibility[row.id] === false) hidden += 1;
}
return {
total: rows.length,
orphan,
missingTime,
partialTime,
timeIssues: missingTime + partialTime,
filteredOut,
hidden,
};
}, [geometryVisibility, rows]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
onFocusGeometry?.(geometryId);
};
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
userSelect: "none",
}}
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
>
<button
type="button"
role="switch"
aria-checked={bindingFilterEnabled}
aria-label="Toggle geometry binding filter"
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
style={{
width: 32,
height: 18,
padding: 2,
borderRadius: 999,
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
transition: "background 140ms ease, border-color 140ms ease",
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: 999,
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
transition: "background 140ms ease, box-shadow 140ms ease",
}}
/>
</button>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={summaryWrapStyle}>
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
{summary.orphan > 0 ? (
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
) : null}
{summary.timeIssues > 0 ? (
<span
style={summaryWarningBadgeStyle}
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
>
time {summary.timeIssues}
</span>
) : null}
{summary.filteredOut > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
) : null}
{summary.hidden > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
) : null}
</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
title={collapsed ? "Mở panel" : "Thu gọn panel"}
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const isBound = childSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border:
"1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
>
<div
style={{
fontSize: 10,
color: "#93c5fd",
fontWeight: 900,
textTransform: "uppercase",
lineHeight: 1,
marginBottom: 5,
}}
>
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
</div>
);
})()
) : null}
{collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows
.map((g) => {
const isBound = childSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false;
const title = buildGeometryTitle(g, isHidden, isBound);
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
minWidth: 0,
}}
>
<GeometryLabel row={g} />
{g.isNew ? <NewBadge /> : null}
</div>
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
</div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? (
<button
type="button"
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={(event) => {
event.stopPropagation();
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
}}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBound
? `Unbind geometry ${g.id} from selected geometry`
: `Bind geometry ${g.id} to selected geometry`
}
>
{isBound ? <UnlockIcon /> : <LockIcon />}
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
No geometry yet for this project.
</div>
)}
{collapsed ? null : statusText ? (
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
{statusText}
</div>
) : null}
</div>
);
}
function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) {
return (
<span
style={{
fontSize: "12px",
color,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.label || "Geometry"}
</span>
);
}
function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) {
return (
<div style={statusChipRowStyle}>
{row.isOrphan ? <span style={dangerBadgeStyle}>no entity</span> : null}
{row.timeStatus === "missing" ? <span style={dangerBadgeStyle}>no time</span> : null}
{row.timeStatus === "partial" ? <span style={warningBadgeStyle}>partial time</span> : null}
{row.timelineStatus === "visible" ? <span style={timelineBadgeStyle}>timeline</span> : null}
{row.timelineStatus === "filteredOut" ? <span style={mutedBadgeStyle}>out timeline</span> : null}
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
</div>
);
}
function resolveTimeStatus(geometry: GeometryChoice): GeometryTimeStatus {
if (geometry.timeStatus === "missing" || geometry.timeStatus === "partial" || geometry.timeStatus === "complete") {
return geometry.timeStatus;
}
const hasStart = normalizeTimelineYearValue(geometry.time_start) !== null;
const hasEnd = normalizeTimelineYearValue(geometry.time_end) !== null;
if (!hasStart && !hasEnd) return "missing";
if (!hasStart || !hasEnd) return "partial";
return "complete";
}
function resolveTimelineStatus(geometry: GeometryChoice): GeometryTimelineStatus {
if (
geometry.timelineStatus === "off" ||
geometry.timelineStatus === "visible" ||
geometry.timelineStatus === "filteredOut"
) {
return geometry.timelineStatus;
}
return geometry.isTimelineVisible ? "visible" : "off";
}
function buildGeometryTitle(row: GeometryRow, isHidden: boolean, isBound: boolean): string {
const parts = [`ID: ${row.id}`];
if (row.isOrphan) parts.push("Orphan");
if (row.timeStatus === "missing") parts.push("Missing time");
if (row.timeStatus === "partial") parts.push("Partial time");
if (row.timelineStatus === "visible") parts.push("Timeline visible");
if (row.timelineStatus === "filteredOut") parts.push("Filtered out by timeline");
if (isHidden) parts.push("Hidden");
if (isBound) parts.push("Bound");
if (row.isNew) parts.push("New");
return parts.join(" | ");
}
const summaryWrapStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
minWidth: 0,
flexWrap: "wrap",
};
const baseBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
whiteSpace: "nowrap",
};
const summaryBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.35)",
background: "rgba(15, 23, 42, 0.9)",
color: "#cbd5e1",
};
const summaryDangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.32)",
color: "#fecaca",
};
const summaryWarningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.48)",
background: "rgba(113, 63, 18, 0.3)",
color: "#fde68a",
};
const summaryMutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.4)",
background: "rgba(51, 65, 85, 0.32)",
color: "#cbd5e1",
};
const statusChipRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
marginTop: 5,
minHeight: 17,
};
const dangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.28)",
color: "#fecaca",
};
const warningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.5)",
background: "rgba(113, 63, 18, 0.28)",
color: "#fde68a",
};
const timelineBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(34, 197, 94, 0.5)",
background: "rgba(20, 83, 45, 0.3)",
color: "#bbf7d0",
};
const mutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.28)",
color: "#cbd5e1",
};
const boundBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
};
const hiddenBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export default memo(GeometryBindingPanel);
@@ -0,0 +1,127 @@
"use client";
import type { ChangeEvent } from "react";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
overlay: MapImageOverlay | null;
onPickImage: (file: File | null) => void;
onPasteImage: () => void;
keyboardEnabled: boolean;
onKeyboardEnabledChange: (enabled: boolean) => void;
onOpacityChange: (opacity: number) => void;
onRemove: () => void;
};
export default function ImageOverlayPanel({
overlay,
onPickImage,
onPasteImage,
keyboardEnabled,
onKeyboardEnabledChange,
onOpacityChange,
onRemove,
}: Props) {
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
onPickImage(file);
event.target.value = "";
};
return (
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
Chuột phải kéo điểm vàng đ di chuyển, điểm xanh đ kéo dãn giữ ratio.
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
</div>
</div>
{overlay ? (
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
Remove
</button>
) : null}
</div>
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
<label style={uploadButtonStyle}>
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
Paste nh
</button>
<button
type="button"
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
disabled={!overlay}
style={{
...uploadButtonStyle,
opacity: overlay ? 1 : 0.5,
color: keyboardEnabled ? "#86efac" : "#93c5fd",
cursor: overlay ? "pointer" : "not-allowed",
}}
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
>
Keys: {keyboardEnabled ? "On" : "Off"}
</button>
</div>
{overlay ? (
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
{overlay.name}
</div>
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={overlay.opacity}
onChange={(event) => onOpacityChange(Number(event.target.value))}
style={{ width: "100%", accentColor: "#38bdf8" }}
/>
</label>
</div>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
Chưa nh overlay.
</div>
)}
</section>
);
}
const uploadButtonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #334155",
borderRadius: 6,
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "7px 10px",
} as const;
const dangerButtonStyle = {
border: "1px solid #7f1d1d",
borderRadius: 6,
background: "#1f1111",
color: "#fecaca",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "6px 8px",
} as const;
+86
View File
@@ -0,0 +1,86 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ thêm đnh.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div>
);
}
if (mode === "add-circle") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Kéo chuột đ vẽ hình tròn.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Nhấn giữ chuột trái</b>: Chọn tâm & kéo đ tạo bán kính</li>
<li><b>Nhả chuột trái</b>: Hoàn tất chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác đang kéo vẽ dở</li>
</ul>
</div>
);
}
if (mode === "add-point") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ tạo một Điểm.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) chính xác vào hình khác</li>
</ul>
</div>
)
}
if (mode === "select") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click vào hình trên map đ Chọn (Select).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Giữ Shift + click</b>: Chọn nhiều hình hoặc bỏ chọn hình đang chọn</li>
<li><b>Chuột phải</b>: Mở menu chỉnh sửa / xóa / duplicate / replay</li>
<li>Trong chế đ Sửa đnh:
<ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}>
<li><b>Enter</b>: Lưu hình đã sửa</li>
<li><b>Esc</b>: Hủy chỉnh sửa</li>
<li><b>Delete</b>: Bật/Tắt chế đ Xóa đnh (click đ xóa)</li>
<li><b>Giữ Shift</b>: Bắt dính (Snap) điểm đang kéo</li>
</ul>
</li>
</ul>
</div>
)
}
if (mode === "draw") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ vẽ Đa giác (Polygon).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div>
)
}
if (mode === "replay") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang trong chế đ trình diễn diễn biến kịch bản.
</div>
)
}
if (mode === "replay_preview") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang xem preview replay trên session tách biệt.
</div>
)
}
return null;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { CSSProperties } from "react";
type Props = {
title?: string;
};
export default function NewBadge({ title = "Created in this session and not committed yet" }: Props) {
return (
<span style={badgeStyle} title={title}>
new
</span>
);
}
const badgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.55)",
background: "rgba(20, 184, 166, 0.16)",
color: "#5eead4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
+91
View File
@@ -0,0 +1,91 @@
import { type ReactNode, useState } from "react";
type PanelProps = {
title: string;
badge?: string | null;
defaultOpen?: boolean;
children: ReactNode;
};
export function Panel({
title,
badge,
defaultOpen,
children,
}: PanelProps) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<details
open={open}
onToggle={(e) => setOpen(e.currentTarget.open)}
style={{
marginTop: 10,
padding: 10,
background: "#111827",
borderRadius: 8,
border: "1px solid #1f2937",
}}
>
<summary
style={{
cursor: "pointer",
listStyle: "none",
fontWeight: 900,
fontSize: 13,
color: "white",
userSelect: "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span>{title}</span>
{badge && (
<span
style={{
fontSize: 11,
fontWeight: 500,
background: "#374151",
color: "#cbd5e1",
padding: "2px 6px",
borderRadius: 4,
}}
>
{badge}
</span>
)}
</div>
<span
style={{
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#cbd5e1",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 700,
flex: "0 0 auto",
}}
>
{open ? "" : "+"}
</span>
</summary>
<style>{`
summary::-webkit-details-marker {
display: none !important;
}
summary {
list-style: none !important;
}
`}</style>
<div style={{ marginTop: 10 }}>{children}</div>
</details>
);
}
@@ -0,0 +1,788 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import {
fetchPresentPlaceDetail,
hasSearchMapApiKey,
reverseGeocodePresentPlace,
searchPresentPlaces,
type PresentPlacePrediction,
type PresentPlaceSelection,
} from "@/uhm/api/goongPlaces";
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
export type { PresentPlaceSelection } from "@/uhm/api/goongPlaces";
export type HistoricalGeometryFocusPayload = {
entity: EntityGeometriesSearchItem;
geometry: EntityGeometrySearchGeo;
representativePoint: [number, number] | null;
adminLabel: string | null;
};
type SearchMode = "present" | "history" | "wiki";
type AdminLabelState = {
status: "loading" | "loaded" | "error";
label: string | null;
address: string | null;
};
type Props = {
focusedPlace: PresentPlaceSelection | null;
onFocusPlace: (place: PresentPlaceSelection) => void;
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
onFocusWiki?: (wiki: Wiki) => void;
onClearFocus: () => void;
leftOffset?: number;
style?: CSSProperties;
};
export default function PresentPlaceSearch({
focusedPlace,
onFocusPlace,
onFocusHistoricalGeometry,
onFocusWiki,
onClearFocus,
leftOffset = 18,
style,
}: Props) {
const [mode, setMode] = useState<SearchMode>("history");
const [query, setQuery] = useState("");
const [results, setResults] = useState<PresentPlacePrediction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectingPlaceId, setSelectingPlaceId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [historicalQuery, setHistoricalQuery] = useState("");
const [historicalResults, setHistoricalResults] = useState<EntityGeometriesSearchItem[]>([]);
const [isHistoricalLoading, setIsHistoricalLoading] = useState(false);
const [historicalError, setHistoricalError] = useState<string | null>(null);
const [expandedEntityId, setExpandedEntityId] = useState<string | null>(null);
const [selectingGeometryId, setSelectingGeometryId] = useState<string | null>(null);
const [adminLabels, setAdminLabels] = useState<Record<string, AdminLabelState>>({});
const [wikiQuery, setWikiQuery] = useState("");
const [wikiResults, setWikiResults] = useState<Wiki[]>([]);
const [isWikiLoading, setIsWikiLoading] = useState(false);
const [wikiError, setWikiError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const requestSeqRef = useRef(0);
const historicalRequestSeqRef = useRef(0);
const wikiRequestSeqRef = useRef(0);
const hasApiKey = hasSearchMapApiKey();
const activeQuery = mode === "present" ? query : mode === "history" ? historicalQuery : wikiQuery;
const activeError = mode === "present" ? error : mode === "history" ? historicalError : wikiError;
const activeLoading = mode === "present" ? isLoading : mode === "history" ? isHistoricalLoading : isWikiLoading;
const expandedItem = useMemo(() => {
if (!expandedEntityId) return null;
return historicalResults.find((item) => item.entity_id === expandedEntityId) || null;
}, [expandedEntityId, historicalResults]);
useEffect(() => {
if (mode !== "present") return;
const keyword = query.trim();
if (!keyword || keyword.length < 2) {
setResults([]);
setIsLoading(false);
setError(null);
return;
}
if (!hasApiKey) {
setResults([]);
setIsLoading(false);
setError("Thiếu SEARCH_MAP_API_KEY.");
return;
}
const controller = new AbortController();
const seq = requestSeqRef.current + 1;
requestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsLoading(true);
setError(null);
searchPresentPlaces(keyword, controller.signal)
.then((nextResults) => {
if (requestSeqRef.current !== seq) return;
setResults(nextResults);
setIsOpen(true);
})
.catch((err) => {
if (controller.signal.aborted || requestSeqRef.current !== seq) return;
setResults([]);
setError(err instanceof Error ? err.message : "Không tìm được địa điểm.");
})
.finally(() => {
if (requestSeqRef.current === seq) {
setIsLoading(false);
}
});
}, 260);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [hasApiKey, mode, query]);
useEffect(() => {
if (mode !== "history") return;
const keyword = historicalQuery.trim();
if (!keyword || keyword.length < 2) {
setHistoricalResults([]);
setIsHistoricalLoading(false);
setHistoricalError(null);
setExpandedEntityId(null);
return;
}
const seq = historicalRequestSeqRef.current + 1;
historicalRequestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsHistoricalLoading(true);
setHistoricalError(null);
searchGeometriesByEntityName(keyword, { limit: 12 })
.then((response) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults(response.items || []);
setExpandedEntityId(null);
setIsOpen(true);
})
.catch((err) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults([]);
setHistoricalError(err instanceof Error ? err.message : "Không tìm được thực thể lịch sử.");
})
.finally(() => {
if (historicalRequestSeqRef.current === seq) {
setIsHistoricalLoading(false);
}
});
}, 260);
return () => window.clearTimeout(timer);
}, [historicalQuery, mode]);
useEffect(() => {
if (mode !== "wiki") return;
const keyword = wikiQuery.trim();
if (!keyword || keyword.length < 2) {
setWikiResults([]);
setIsWikiLoading(false);
setWikiError(null);
return;
}
const seq = wikiRequestSeqRef.current + 1;
wikiRequestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsWikiLoading(true);
setWikiError(null);
searchWikisByTitle(keyword, { limit: 12 })
.then((nextResults) => {
if (wikiRequestSeqRef.current !== seq) return;
setWikiResults(nextResults || []);
setIsOpen(true);
})
.catch((err) => {
if (wikiRequestSeqRef.current !== seq) return;
setWikiResults([]);
setWikiError(err instanceof Error ? err.message : "Không tìm được bài viết wiki.");
})
.finally(() => {
if (wikiRequestSeqRef.current === seq) {
setIsWikiLoading(false);
}
});
}, 260);
return () => window.clearTimeout(timer);
}, [wikiQuery, mode]);
useEffect(() => {
if (mode !== "history" || !expandedItem || expandedItem.geometries.length <= 1 || !hasApiKey) {
return;
}
const controller = new AbortController();
for (const geometry of expandedItem.geometries) {
const point = getGeometryRepresentativePoint(geometry.draw_geometry);
if (!point) {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
continue;
}
setAdminLabels((prev) => {
if (prev[geometry.id]) return prev;
return {
...prev,
[geometry.id]: { status: "loading", label: null, address: null },
};
});
reverseGeocodePresentPlace(point[0], point[1], controller.signal)
.then((place) => {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: {
status: "loaded",
label: place.label,
address: place.address,
},
}));
})
.catch((err) => {
if (controller.signal.aborted) return;
console.warn("Reverse geocode historical geometry failed", err);
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
});
}
return () => controller.abort();
}, [expandedItem, hasApiKey, mode]);
const selectPrediction = async (prediction: PresentPlacePrediction) => {
setSelectingPlaceId(prediction.placeId);
setError(null);
try {
const place = await fetchPresentPlaceDetail(prediction.placeId);
onFocusPlace(place);
setQuery(place.name || prediction.description);
setResults([]);
setIsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Không lấy được tọa độ địa điểm.");
} finally {
setSelectingPlaceId(null);
}
};
const selectHistoricalEntity = (item: EntityGeometriesSearchItem) => {
if (item.geometries.length === 1) {
selectHistoricalGeometry(item, item.geometries[0]);
return;
}
if (item.geometries.length > 1) {
setExpandedEntityId((prev) => prev === item.entity_id ? null : item.entity_id);
}
};
const selectHistoricalGeometry = (
item: EntityGeometriesSearchItem,
geometry: EntityGeometrySearchGeo
) => {
setSelectingGeometryId(geometry.id);
const labelState = adminLabels[geometry.id] || null;
onFocusHistoricalGeometry({
entity: item,
geometry,
representativePoint: getGeometryRepresentativePoint(geometry.draw_geometry),
adminLabel: labelState?.label || null,
});
setHistoricalQuery(item.name);
setHistoricalResults([]);
setExpandedEntityId(null);
setIsOpen(false);
setSelectingGeometryId(null);
};
const selectWiki = (wiki: Wiki) => {
if (onFocusWiki) {
onFocusWiki(wiki);
}
setWikiQuery(wiki.title || "");
setWikiResults([]);
setIsOpen(false);
};
const clearSearch = () => {
if (mode === "present") {
setQuery("");
setResults([]);
setError(null);
} else if (mode === "history") {
setHistoricalQuery("");
setHistoricalResults([]);
setHistoricalError(null);
setExpandedEntityId(null);
} else {
setWikiQuery("");
setWikiResults([]);
setWikiError(null);
}
setIsOpen(false);
onClearFocus();
};
const cycleMode = () => {
let nextMode: SearchMode;
if (mode === "history") {
nextMode = "present";
} else if (mode === "present") {
nextMode = "wiki";
} else {
nextMode = "history";
}
setMode(nextMode);
setIsOpen(true);
setError(null);
setHistoricalError(null);
setWikiError(null);
};
return (
<div
style={{
position: "absolute",
top: 10,
left: "50%",
transform: "translateX(-50%)",
zIndex: 18,
width: "min(392px, calc(100vw - 36px))",
pointerEvents: "auto",
...style,
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div style={searchCardStyle}>
<div style={searchInputRowStyle}>
<button
type="button"
onClick={cycleMode}
title={`Đổi chế độ tìm kiếm (hiện tại: ${getSearchModeLabel(mode)})`}
aria-label={`Đổi chế độ tìm kiếm (hiện tại: ${getSearchModeLabel(mode)})`}
style={modeSwitchStyle}
>
{getSearchModeLabel(mode)}
</button>
<input
value={activeQuery}
onChange={(event) => {
if (mode === "present") {
setQuery(event.target.value);
} else if (mode === "history") {
setHistoricalQuery(event.target.value);
} else {
setWikiQuery(event.target.value);
}
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
return;
}
if (event.key === "Enter") {
if (mode === "present" && results[0]) {
event.preventDefault();
void selectPrediction(results[0]);
}
if (mode === "history" && historicalResults[0]) {
event.preventDefault();
selectHistoricalEntity(historicalResults[0]);
}
if (mode === "wiki" && wikiResults[0]) {
event.preventDefault();
selectWiki(wikiResults[0]);
}
}
}}
disabled={mode === "present" && !hasApiKey}
placeholder={
mode === "present"
? "Tìm địa điểm hiện tại"
: mode === "history"
? "Tìm thực thể lịch sử"
: "Tìm bài viết wiki"
}
style={inputStyle}
/>
{activeQuery || focusedPlace ? (
<button
type="button"
onClick={clearSearch}
title="Xóa tìm kiếm"
aria-label="Xóa ô tìm kiếm"
style={clearButtonStyle}
>
x
</button>
) : null}
</div>
</div>
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults, wikiResults) ? (
<div style={resultsPanelStyle}>
{mode === "present" ? (
<PresentResults
isLoading={isLoading}
error={error}
query={query}
results={results}
selectingPlaceId={selectingPlaceId}
onSelect={selectPrediction}
/>
) : mode === "history" ? (
<HistoricalResults
isLoading={isHistoricalLoading}
error={historicalError}
query={historicalQuery}
results={historicalResults}
expandedEntityId={expandedEntityId}
adminLabels={adminLabels}
selectingGeometryId={selectingGeometryId}
hasApiKey={hasApiKey}
onSelectEntity={selectHistoricalEntity}
onSelectGeometry={selectHistoricalGeometry}
/>
) : (
<WikiResults
isLoading={isWikiLoading}
error={wikiError}
query={wikiQuery}
results={wikiResults}
onSelect={selectWiki}
/>
)}
</div>
) : null}
</div>
);
}
function PresentResults({
isLoading,
error,
query,
results,
selectingPlaceId,
onSelect,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: PresentPlacePrediction[];
selectingPlaceId: string | null;
onSelect: (prediction: PresentPlacePrediction) => Promise<void>;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không kết quả.</div>;
return (
<>
{results.map((result) => (
<button
key={result.placeId}
type="button"
onClick={() => void onSelect(result)}
disabled={selectingPlaceId === result.placeId}
style={{
...resultButtonStyle,
cursor: selectingPlaceId === result.placeId ? "wait" : "pointer",
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{result.mainText}</span>
<span style={secondaryResultTextStyle}>{result.secondaryText || result.description}</span>
</button>
))}
</>
);
}
function HistoricalResults({
isLoading,
error,
query,
results,
expandedEntityId,
adminLabels,
selectingGeometryId,
hasApiKey,
onSelectEntity,
onSelectGeometry,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: EntityGeometriesSearchItem[];
expandedEntityId: string | null;
adminLabels: Record<string, AdminLabelState>;
selectingGeometryId: string | null;
hasApiKey: boolean;
onSelectEntity: (item: EntityGeometriesSearchItem) => void;
onSelectGeometry: (item: EntityGeometriesSearchItem, geometry: EntityGeometrySearchGeo) => void;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm thực thể...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không thực thể phù hợp.</div>;
return (
<>
{results.map((item) => {
const isExpanded = expandedEntityId === item.entity_id;
return (
<div key={item.entity_id} style={{ borderBottom: "1px solid rgba(148, 163, 184, 0.12)" }}>
<button
type="button"
onClick={() => onSelectEntity(item)}
disabled={!item.geometries.length}
style={{
...resultButtonStyle,
cursor: item.geometries.length ? "pointer" : "not-allowed",
}}
onMouseEnter={(event) => {
if (item.geometries.length) event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{item.name || item.entity_id}</span>
<span style={secondaryResultTextStyle}>
{item.geometries.length
? `${item.geometries.length} hình bản đồ`
: "Không có hình bản đồ"}
{item.description ? ` · ${item.description}` : ""}
</span>
</button>
{isExpanded ? (
<div style={{ padding: "0 8px 8px", display: "grid", gap: 6 }}>
{!hasApiKey ? (
<div style={{ ...statusStyle, padding: "7px 8px" }}>
Thiếu SEARCH_MAP_API_KEY đ lấy đa danh hiện tại.
</div>
) : null}
{item.geometries.map((geometry) => (
<button
key={geometry.id}
type="button"
onClick={() => onSelectGeometry(item, geometry)}
disabled={selectingGeometryId === geometry.id}
style={{
border: "1px solid rgba(148, 163, 184, 0.16)",
borderRadius: 8,
background: "rgba(15, 23, 42, 0.68)",
color: "#e2e8f0",
padding: "8px 9px",
textAlign: "left",
cursor: selectingGeometryId === geometry.id ? "wait" : "pointer",
}}
>
<span style={{ ...primaryResultTextStyle, fontSize: 12 }}>
{formatAdminLabel(adminLabels[geometry.id])}
</span>
<span style={{ ...secondaryResultTextStyle, marginTop: 3 }}>
{formatGeometryMeta(geometry)}
</span>
</button>
))}
</div>
) : null}
</div>
);
})}
</>
);
}
function WikiResults({
isLoading,
error,
query,
results,
onSelect,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: Wiki[];
onSelect: (wiki: Wiki) => void;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm wiki...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không tìm thấy bài viết.</div>;
return (
<>
{results.map((wiki) => (
<button
key={wiki.id}
type="button"
onClick={() => onSelect(wiki)}
style={resultButtonStyle}
onMouseEnter={(event) => {
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{wiki.title || wiki.slug || "Không có tiêu đề"}</span>
{wiki.slug && (
<span style={secondaryResultTextStyle}>/wiki/{wiki.slug}</span>
)}
</button>
))}
</>
);
}
function shouldRenderResults(
mode: SearchMode,
query: string,
isLoading: boolean,
error: string | null,
presentResults: PresentPlacePrediction[],
historicalResults: EntityGeometriesSearchItem[],
wikiResults: Wiki[]
): boolean {
if (isLoading || error || query.trim().length >= 2) return true;
if (mode === "present") return presentResults.length > 0;
if (mode === "history") return historicalResults.length > 0;
return wikiResults.length > 0;
}
function formatAdminLabel(state: AdminLabelState | undefined): string {
if (!state || state.status === "loading") return "Đang lấy địa danh hiện tại...";
if (state.status === "error") return "Không lấy được địa danh hiện tại";
return state.label || state.address || "Địa danh hiện tại không rõ";
}
function formatGeometryMeta(geometry: EntityGeometrySearchGeo): string {
const type = geometry.type || "hình bản đồ";
const timeStart = geometry.time_start ?? null;
const timeEnd = geometry.time_end ?? null;
const time =
timeStart !== null && timeEnd !== null
? `${timeStart} - ${timeEnd}`
: timeStart !== null
? `từ ${timeStart}`
: timeEnd !== null
? `đến ${timeEnd}`
: "không rõ thời gian";
return `${type} · ${time}`;
}
const searchCardStyle = {
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.92)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.35)",
color: "#e2e8f0",
overflow: "hidden",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const searchInputRowStyle = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 10px",
} satisfies CSSProperties;
const inputStyle = {
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
color: "#f8fafc",
fontSize: 13,
fontWeight: 700,
} satisfies CSSProperties;
const modeSwitchStyle = {
border: "none",
borderRight: "1px solid rgba(148, 163, 184, 0.22)",
background: "transparent",
color: "#38bdf8",
padding: "0 10px 0 0",
fontSize: 11,
fontWeight: 900,
cursor: "pointer",
lineHeight: 1,
} satisfies CSSProperties;
const clearButtonStyle = {
width: 26,
height: 26,
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 6,
background: "rgba(15, 23, 42, 0.74)",
color: "#cbd5e1",
cursor: "pointer",
fontWeight: 900,
} satisfies CSSProperties;
const resultsPanelStyle = {
marginTop: 8,
overflow: "hidden",
border: "1px solid rgba(148, 163, 184, 0.24)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.96)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.4)",
color: "#e2e8f0",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const resultButtonStyle = {
display: "grid",
gap: 3,
width: "100%",
padding: "10px 12px",
border: "none",
background: "transparent",
color: "#e2e8f0",
textAlign: "left",
} satisfies CSSProperties;
const primaryResultTextStyle = {
display: "block",
fontSize: 13,
fontWeight: 900,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const secondaryResultTextStyle = {
display: "block",
fontSize: 11,
color: "#94a3b8",
lineHeight: 1.35,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const statusStyle = {
padding: "11px 12px",
color: "#94a3b8",
fontSize: 12,
fontWeight: 700,
} satisfies CSSProperties;
function getSearchModeLabel(mode: SearchMode): string {
if (mode === "present") return "Hiện tại";
if (mode === "history") return "Lịch sử";
return "Wiki";
}
@@ -0,0 +1,677 @@
"use client";
import { useMemo, useState, memo, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
import { newId } from "@/uhm/lib/utils/id";
type Props = {
onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
hasSelectedGeometry?: boolean;
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
onRerollEntityId?: (oldId: string, nextId: string) => void;
onDeleteEntity?: (entityId: string) => void;
};
function ProjectEntityRefsPanel({
onCreateEntityOnly,
onUpdateEntity,
hasSelectedGeometry,
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry,
onRerollEntityId,
onDeleteEntity,
}: Props) {
const {
snapshotEntityRows,
entityForm,
setEntityForm,
isEntitySubmitting,
entityFormStatus,
selectedGeometryEntityIds,
} = useEditorStore(
useShallow((state) => ({
snapshotEntityRows: state.snapshotEntityRows,
entityForm: state.entityForm,
setEntityForm: state.setEntityForm,
isEntitySubmitting: state.isEntitySubmitting,
entityFormStatus: state.entityFormStatus,
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
}))
);
const canBindToggle =
Boolean(hasSelectedGeometry) &&
Array.isArray(selectedGeometryEntityIds) &&
typeof onToggleBindEntityForSelectedGeometry === "function";
const canEditEntity = typeof onUpdateEntity === "function";
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const selectedEntityIdSet = useMemo(
() => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds]
);
const entityRefs = useMemo(() => {
const byId = new globalThis.Map<string, EntitySnapshot>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || byId.has(id)) continue;
if (ref.operation === "delete") continue;
byId.set(id, ref);
}
return Array.from(byId.values());
}, [snapshotEntityRows]);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
const aBound = selectedEntityIdSet.has(String(a.id));
const bBound = selectedEntityIdSet.has(String(b.id));
if (aBound !== bBound) return aBound ? -1 : 1;
const aLabel = String(a.name || a.id || "");
const bLabel = String(b.name || b.id || "");
return aLabel.localeCompare(bLabel);
});
return rows;
}, [entityRefs, selectedEntityIdSet]);
const activeEntity = useMemo(
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
[activeEntityId, entityRefs]
);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editTimeStart, setEditTimeStart] = useState("");
const [editTimeEnd, setEditTimeEnd] = useState("");
const canCopySelectedGeometryTime =
selectedGeometryTime != null &&
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description));
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
};
const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const copySelectedGeometryTimeToCreateForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEntityForm((prev) => ({
...prev,
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
}));
};
const copySelectedGeometryTimeToEditForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Entities" : "Thu gon panel Entities"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : sortedEntityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{sortedEntityRefs.map((e) => {
const entityId = String(e.id);
const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
const isActive = activeEntityId === entityId;
return (
<div
key={e.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isActive
? "1px solid #2563eb"
: isBoundToSelectedGeometry
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBoundToSelectedGeometry ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<button
type="button"
onClick={() => openEntityEditor(e)}
title="Chon de sua"
style={{
flex: 1,
minWidth: 0,
textAlign: "left",
border: "none",
background: "transparent",
padding: 0,
cursor: canEditEntity ? "pointer" : "default",
}}
disabled={!canEditEntity}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</span>
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
{isNewEntityRef(e) ? <NewBadge /> : null}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button>
{canBindToggle ? (
<button
type="button"
title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
entityId,
!isBoundToSelectedGeometry
)
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBoundToSelectedGeometry
? `Unbind entity ${entityId} from selected geometry`
: `Bind entity ${entityId} to selected geometry`
}
>
{isBoundToSelectedGeometry ? (
<UnlockIcon />
) : (
<LockIcon />
)}
</button>
) : null}
{typeof onDeleteEntity === "function" ? (
<button
type="button"
title="Xóa thực thể khỏi dự án"
onClick={() => onDeleteEntity(entityId)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={`Xóa thực thể ${entityId}`}
>
<TrashIcon />
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
)}
{collapsed ? null : canEditEntity && activeEntity ? (
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #0f766e",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
Sua entity
</span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={copySelectedGeometryTimeToEditForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={iconButtonStyle(false)}
>
<CloseIcon />
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
{String(activeEntity.id)}
</div>
{activeEntity.source === "inline" && onRerollEntityId && (
<button
type="button"
onClick={() => {
const nextId = newId();
onRerollEntityId(String(activeEntity.id), nextId);
setActiveEntityId(nextId);
}}
disabled={isEntitySubmitting}
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho thực thể này"
style={{
border: "1px solid #0f766e",
borderRadius: "4px",
padding: "2px 6px",
background: "transparent",
color: "#14b8a6",
fontSize: "11px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
flex: "0 0 auto",
}}
>
Đi màu (Reroll ID)
</button>
)}
</div>
<input
value={editName}
onChange={(event) => setEditName(event.target.value)}
placeholder="Ten entity"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={editTimeStart}
onChange={(event) => setEditTimeStart(event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editTimeEnd}
onChange={(event) => setEditTimeEnd(event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Luu entity
</button>
{typeof onDeleteEntity === "function" && (
<button
type="button"
onClick={() => {
onDeleteEntity(String(activeEntity.id));
setActiveEntityId(null);
}}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#7f1d1d",
color: "#fecaca",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Xóa
</button>
)}
</div>
</div>
) : null}
{collapsed ? null : (
<>
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isCreateOpen ? (
<button
type="button"
onClick={copySelectedGeometryTimeToCreateForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity moi"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
) : null}
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={iconButtonStyle(isEntitySubmitting)}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
</div>
{isCreateOpen ? (
<>
<input
value={entityForm.name}
onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.description}
onChange={(event) => handleEntityFormChange("description", event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={entityForm.time_start}
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.time_end}
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
onClick={onCreateEntityOnly}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới
</button>
</>
) : null}
</div>
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus}
</div>
) : null}
</>
)}
</div>
);
}
function isNewEntityRef(entity: EntitySnapshot | null | undefined): boolean {
return entity?.source === "inline" && entity?.operation === "create";
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const timeInputGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
};
function iconButtonStyle(disabled: boolean): CSSProperties {
return {
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
};
}
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function ClockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function TrashIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16"
stroke="#f87171"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default memo(ProjectEntityRefsPanel);
@@ -0,0 +1,36 @@
import { Panel } from "./Panel";
type ProjectPanelProps = {
sectionTitle: string;
projectStatus: string;
commitCount: number;
latestCommitLabel: string | null;
};
export function ProjectPanel({
sectionTitle,
projectStatus,
commitCount,
latestCommitLabel,
}: ProjectPanelProps) {
return (
<Panel title="Project" defaultOpen>
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
<div style={{ marginTop: 6 }}>
Status: <span style={{ color: "#e2e8f0" }}>{projectStatus}</span>
</div>
<div style={{ marginTop: 6 }}>
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
</div>
<div style={{ marginTop: 6 }}>
{latestCommitLabel ? (
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
) : (
<span style={{ color: "#94a3b8" }}>Chưa head commit</span>
)}
</div>
</div>
</Panel>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,404 @@
"use client";
import type { BackgroundLayerId } from "@/uhm/lib/map/styles/backgroundLayers";
import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers";
type Props = {
backgroundVisibility: Record<string, boolean>;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
onHide?: () => void;
};
// Map each layer ID/geometry type to a premium inline SVG icon
const LAYER_ICONS: Record<string, React.ReactNode> = {
// Background layers
"raster-base-layer": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
"bg-country-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h18M3 6h18M3 18h18" strokeDasharray="2 2" />
<rect x="2" y="2" width="20" height="20" rx="4" />
</svg>
),
"bg-province-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" strokeDasharray="3 3" />
<rect x="2" y="2" width="20" height="20" rx="3" />
</svg>
),
"bg-district-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3v18M12 3v18M18 3v18M3 6h18M3 12h18M3 18h18" strokeDasharray="1 3" />
<rect x="2" y="2" width="20" height="20" rx="2" />
</svg>
),
"country-labels": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V4h16v3M9 20h6M12 4v16" />
</svg>
),
"rivers-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12c3-3 6-3 9 0s6 3 9 0" />
<path d="M3 16c3-3 6-3 9 0s6 3 9 0" opacity="0.6" />
</svg>
),
// Polygon Geometries
country: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5" />
</svg>
),
state: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 4 20 9 20 15 12 20 4 15 4 9" />
</svg>
),
faction: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7" />
</svg>
),
rebellion_zone: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>
),
// Line Geometries
defense_line: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2" />
<path d="M12 2v9M8 5v3M16 5v3" />
</svg>
),
military_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M12 12l9 9M18 15h3v3" />
</svg>
),
retreat_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
),
migration_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
trade_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8M12 6v12" />
</svg>
),
// Point Geometries
battle: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M8.5 19.5L19.5 8.5" />
</svg>
),
person_event: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
temple: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7h20L12 2zM4 7v10h16V7H4zm2 10v4h2v-4H6zm10 0v4h2v-4h-2z" />
</svg>
),
capital: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="12 7 13.9 10.8 18.1 11.4 15.1 14.4 15.8 18.6 12 16.6 8.2 18.6 8.9 14.4 5.9 11.4 10.1 10.8" fill="currentColor" />
</svg>
),
city: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="9" y1="22" x2="9" y2="16" />
<line x1="15" y1="22" x2="15" y2="16" />
<line x1="9" y1="16" x2="15" y2="16" />
<path d="M8 6h2M14 6h2M8 10h2M14 10h2" strokeWidth="1.5" />
</svg>
),
fortification: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 22V8l3-3h10l3 3v14H4z" />
<path d="M9 22v-6h6v6H9z" />
<path d="M8 8h8M12 5v3" />
</svg>
),
ruin: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="20" x2="6" y2="4" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="3" y1="4" x2="9" y2="4" />
<line x1="15" y1="4" x2="21" y2="4" />
<line x1="3" y1="20" x2="21" y2="20" />
<line x1="6" y1="12" x2="18" y2="12" strokeDasharray="3 3" />
</svg>
),
port: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="22" x2="12" y2="8" />
<path d="M5 12h14M12 12c-4 0-6 4-6 6a6 6 0 0 0 12 0c0-2-2-6-6-6z" />
</svg>
),
};
export default function ReplayPreviewLayerPanel({
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
onHide,
}: Props) {
// Categorize geometry types for logical grouping
const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
const lineKeys = ["defense_line", "military_route", "retreat_route", "migration_route", "trade_route"];
const pointKeys = ["battle", "person_event", "temple", "capital", "city", "fortification", "ruin", "port"];
const getButtonStyles = (isActive: boolean, activeColor: string): React.CSSProperties => ({
border: "none",
background: isActive ? `rgba(${activeColor}, 0.18)` : "rgba(30, 41, 59, 0.4)",
color: isActive ? `rgb(${activeColor})` : "#64748b",
width: 36,
height: 36,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: isActive ? `inset 0 0 0 1px rgba(${activeColor}, 0.3), 0 0 12px rgba(${activeColor}, 0.2)` : "inset 0 0 0 1px rgba(148, 163, 184, 0.1)",
outline: "none",
});
const renderStyles = () => (
<style dangerouslySetInnerHTML={{ __html: `
.replay-preview-layer-panel-scroll::-webkit-scrollbar {
display: none;
}
.replay-preview-layer-panel-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
`}} />
);
return (
<div
className="replay-preview-layer-panel"
style={{
display: "flex",
flexDirection: "column",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))",
border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20,
padding: "14px 10px",
width: 58,
boxSizing: "border-box",
alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
maxHeight: "100%",
overflowX: "hidden",
overflowY: "hidden",
}}
>
{renderStyles()}
<div
className="replay-preview-layer-panel-scroll"
style={{
flexGrow: 1,
overflowY: "auto",
overflowX: "hidden",
width: "100%",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
{/* Background layers */}
<div style={groupHeaderStyle}>Bản đ</div>
<div style={gridStyle}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
title={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
{LAYER_ICONS[layer.id] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Territories / Polygons */}
<div style={groupHeaderStyle}>Khu vực</div>
<div style={gridStyle}>
{polygonKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Routes / Lines */}
<div style={groupHeaderStyle}>Tuyến</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Điểm</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div>
{onHide && (
<div
style={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: 6,
flexShrink: 0,
}}
>
<div style={dividerStyle} />
<button
type="button"
title="Ẩn bảng lớp bản đồ"
onClick={onHide}
style={getButtonStyles(true, "239, 68, 68")}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
</button>
</div>
)}
</div>
);
}
const groupHeaderStyle: React.CSSProperties = {
fontSize: 9,
fontWeight: 900,
color: "#94a3b8",
letterSpacing: 1,
textTransform: "uppercase",
width: "100%",
textAlign: "center",
marginBottom: 4,
};
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
width: "100%",
};
const dividerStyle: React.CSSProperties = {
height: 1,
background: "rgba(148, 163, 184, 0.15)",
width: "80%",
margin: "6px 0",
};
function getGeometryTypeLabel(typeKey: string): string {
const labels: Record<string, string> = {
country: "Quốc gia",
state: "Nhà nước / vùng",
faction: "Phe phái",
rebellion_zone: "Vùng nổi dậy",
defense_line: "Tuyến phòng thủ",
military_route: "Đường hành quân",
retreat_route: "Đường rút lui",
migration_route: "Đường di cư",
trade_route: "Tuyến thương mại",
battle: "Trận đánh",
person_event: "Nhân vật / sự kiện",
temple: "Đền miếu",
capital: "Kinh đô",
city: "Thành phố",
fortification: "Công sự",
ruin: "Di tích",
port: "Cảng",
};
return labels[typeKey] || typeKey.replaceAll("_", " ");
}
@@ -0,0 +1,314 @@
"use client";
import type { CSSProperties } from "react";
import type { DialogState } from "@/uhm/types/projects";
import type { ReplayPreviewToast } from "@/uhm/lib/replay/useReplayPreview";
type Props = {
isPreviewMode: boolean;
isPlaying: boolean;
dialog: DialogState | null;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
sidebarWidth?: number;
playbackSpeed: number;
activeStepLabel: string | null;
activeStepNumber: number | null;
totalSteps: number;
playButtonLabel?: string;
onPlayPreview: () => void;
onStopPreview: () => void;
onResetPreview: () => void;
onExitPreview: () => void;
};
export default function ReplayPreviewOverlay({
isPreviewMode,
isPlaying,
dialog,
toasts,
sidebarOpen,
sidebarWidth = 420,
playbackSpeed,
activeStepLabel,
activeStepNumber,
totalSteps,
playButtonLabel = "Phát lại",
onPlayPreview,
onStopPreview,
onResetPreview,
onExitPreview,
}: Props) {
const hasWikiPreview = sidebarOpen;
const rightOffset = hasWikiPreview ? sidebarWidth + 32 : 18;
const shouldRender =
isPreviewMode ||
isPlaying ||
Boolean(dialog) ||
Boolean(toasts.length);
if (!shouldRender) {
return null;
}
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 60,
pointerEvents: isPreviewMode ? "auto" : "none",
}}
>
{toasts.length ? (
<div
style={{
position: "absolute",
top: 72,
right: rightOffset,
display: "grid",
gap: 8,
width: 280,
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
style={{
borderRadius: 14,
border: "1px solid rgba(56, 189, 248, 0.28)",
background: "rgba(8, 47, 73, 0.9)",
color: "#e0f2fe",
padding: "12px 14px",
fontSize: 13,
lineHeight: 1.4,
boxShadow: "0 10px 26px rgba(2, 6, 23, 0.32)",
}}
>
{toast.message}
</div>
))}
</div>
) : null}
{dialog && (dialog.text?.trim() || dialog.image_url?.trim()) ? (
<div
style={{
position: "absolute",
left: 88,
right: rightOffset,
bottom: 96,
pointerEvents: "none",
display: "flex",
justifyContent: "flex-start",
}}
>
<div
style={{
width: "min(640px, 100%)",
borderRadius: 20,
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(11, 18, 32, 0.85)",
backdropFilter: "blur(12px)",
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
pointerEvents: "auto",
display: "flex",
flexDirection: "column",
maxHeight: "calc(100vh - 180px)",
}}
>
{dialog.image_url?.trim() ? (
<img
src={dialog.image_url}
alt="Hình ảnh lịch sử"
style={{
width: "100%",
display: "block",
maxHeight: 140,
objectFit: "cover",
background: "#020617",
}}
/>
) : null}
{dialog.text?.trim() ? (
<div
className="uhm-replay-dialog-content"
style={{
padding: "16px",
color: "#f8fafc",
fontSize: "14px",
lineHeight: "1.6",
overflowY: "auto",
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
minHeight: 0,
background: "transparent",
}}
dangerouslySetInnerHTML={{ __html: dialog.text }}
/>
) : null}
</div>
</div>
) : null}
<style jsx>{`
.uhm-replay-dialog-content::-webkit-scrollbar {
display: none;
}
.uhm-replay-dialog-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.uhm-replay-dialog-content :global(p) {
margin: 0;
}
.uhm-replay-dialog-content :global(p + p) {
margin-top: 6px;
}
.uhm-replay-dialog-content :global(ul),
.uhm-replay-dialog-content :global(ol) {
margin: 0;
padding-left: 20px;
}
`}</style>
{isPreviewMode ? (
<div
style={{
position: "absolute",
top: 64,
left: "50%",
transform: "translateX(-50%)",
width: "min(520px, calc(100% - 72px))",
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.9)",
color: "#e2e8f0",
padding: "12px 14px",
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.3)",
pointerEvents: "auto",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 8px",
borderRadius: 999,
background: "rgba(34, 197, 94, 0.2)",
color: "#86efac",
fontWeight: 900,
fontSize: 11,
letterSpacing: 0.3,
textTransform: "uppercase",
}}
>
Xem trước
</span>
{activeStepLabel ? (
<span
style={{
fontSize: 13,
fontWeight: 800,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{activeStepLabel}
</span>
) : null}
<span style={{ fontSize: 12, color: "#94a3b8" }}>
x{playbackSpeed.toFixed(2)}
</span>
</div>
{totalSteps > 0 ? (
<div style={{ display: "grid", gap: 6 }}>
<div
style={{
width: "100%",
height: 6,
borderRadius: 999,
background: "rgba(51, 65, 85, 0.8)",
overflow: "hidden",
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, ((activeStepNumber || 0) / totalSteps) * 100))}%`,
height: "100%",
borderRadius: 999,
background: "linear-gradient(90deg, #22c55e, #38bdf8)",
transition: "width 180ms ease",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>
Bước {activeStepNumber || 0}/{totalSteps}
</div>
</div>
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
{isPlaying ? (
<>
<button
type="button"
onClick={onStopPreview}
style={previewButtonStyle("#7f1d1d")}
>
Dừng
</button>
<button
type="button"
onClick={onResetPreview}
style={previewButtonStyle("#1e3a8a")}
>
Phát lại từ đu
</button>
</>
) : (
<button
type="button"
onClick={onPlayPreview}
style={previewButtonStyle("#166534")}
>
{playButtonLabel}
</button>
)}
<button
type="button"
onClick={onExitPreview}
style={previewButtonStyle("#334155")}
>
Thoát xem trước
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function previewButtonStyle(background: string): CSSProperties {
return {
border: "none",
background,
color: "white",
borderRadius: 10,
padding: "8px 12px",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)",
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
"use client";
import { type CSSProperties, memo, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import {
GEOMETRY_TYPE_OPTIONS,
GeometryPreset,
GeometryTypeGroupId,
findGeometryTypeOption,
groupGeometryTypeOptions,
} from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
selectedFeatures: Feature[];
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
onReplayEdit?: (id: string | number) => void;
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
onRerollGeometryId?: (oldId: string | number) => void;
};
function SelectedGeometryPanel({
selectedFeatures,
onApplyGeometryMetadata,
changeCount,
onReplayEdit,
onDeleteFeatures,
onDeselectAll,
onRerollGeometryId,
}: Props) {
const {
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
} = useEditorStore(
useShallow((state) => ({
geometryMetaForm: state.geometryMetaForm,
setGeometryMetaForm: state.setGeometryMetaForm,
isEntitySubmitting: state.isEntitySubmitting,
}))
);
const [collapsed, setCollapsed] = useState(false);
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
| {
kind: "ok" | "error";
text: string;
signature: string;
}
| null
>(null);
const geoMetaSignature = useMemo(() => {
return [
geometryMetaForm.type_key,
geometryMetaForm.time_start,
geometryMetaForm.time_end,
].join("|");
}, [
geometryMetaForm.time_end,
geometryMetaForm.time_start,
geometryMetaForm.type_key,
]);
const handleApplyGeoMeta = async () => {
setGeoApplyFeedback(null);
const result = await onApplyGeometryMetadata();
if (result.ok) {
setGeoApplyFeedback({ kind: "ok", text: "đã apply thành công", signature: geoMetaSignature });
} else if (result.error) {
setGeoApplyFeedback({ kind: "error", text: result.error, signature: geoMetaSignature });
}
};
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
const isBulkMode = selectedFeatures.length >= 2;
const isMultiEditValid = useMemo(() => {
if (selectedFeatures.length <= 1) return true;
const firstShape = selectedFeatures[0].geometry.type;
return selectedFeatures.every((f) => f.geometry.type === firstShape);
}, [selectedFeatures]);
if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0];
const canRerollGeometryId =
!isBulkMode &&
representativeFeature.properties.source !== "ref" &&
Boolean(onRerollGeometryId);
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findGeometryTypeOption(geometryMetaForm.type_key);
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
group.options.some((option) => option.value === geometryMetaForm.type_key)
);
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Selected Geometry" : "Thu gon panel Selected Geometry"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
{collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
{isBulkMode && (
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #334155",
borderRadius: "8px",
padding: "8px",
background: "#1e293b",
}}
>
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
HÀNH ĐNG NHANH
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeselectAll?.()}
style={{
border: "1px solid #475569",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "transparent",
color: "#cbd5e1",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Bỏ chọn tất cả
</button>
</div>
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Thuộc tính GEO
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
</div>
{canRerollGeometryId && onRerollGeometryId && (
<button
type="button"
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho hình học này"
style={{
border: "1px solid #0f766e",
borderRadius: "4px",
padding: "2px 6px",
background: "transparent",
color: "#14b8a6",
fontSize: "11px",
cursor: "pointer",
whiteSpace: "nowrap",
flex: "0 0 auto",
}}
>
Đi màu (Reroll ID)
</button>
)}
</div>
{!isMultiEditValid ? (
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
</div>
) : (
<>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option value={geometryMetaForm.type_key}>
Custom Type ({geometryMetaForm.type_key})
</option>
) : null}
{groupedGeoTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : geometryMetaForm.type_key ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{geometryMetaForm.type_key}</b>
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_start: event.target.value,
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_end: event.target.value,
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
style={{
...primaryGeometryButtonStyle,
background: "#1e293b",
border: "1px solid #334155",
color: "#38bdf8",
}}
>
Replay Edit
</button>
)}
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</div>
) : null}
</>
)}
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Thay đi sẽ vào lịch sử khi Commit.
</div>
) : null}
</div>
)}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const primaryGeometryButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#0f766e",
color: "#ffffff",
fontWeight: 600,
};
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function resolveFeatureGeometryPreset(feature: Feature): GeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findGeometryTypeOption(semanticType);
if (option) return option.geometryPreset;
}
return mapGeometryTypeToPreset(feature.geometry.type);
}
function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
return normalizeGeoTypeKey(value);
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): GeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForPreset(
geometryPreset: GeometryPreset
): GeometryTypeGroupId[] {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryPreset === "line") {
return ["line"];
}
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
export default memo(SelectedGeometryPanel);
+66
View File
@@ -0,0 +1,66 @@
type SubmitModalProps = {
isSubmitModalOpen: boolean;
submitContent: string;
setSubmitContent: (content: string) => void;
handleCancelSubmit: () => void;
handleConfirmSubmit: () => void;
};
export function SubmitModal({
isSubmitModalOpen,
submitContent,
setSubmitContent,
handleCancelSubmit,
handleConfirmSubmit,
}: SubmitModalProps) {
if (!isSubmitModalOpen) return null;
const textAreaStyle = {
width: "100%",
marginTop: 8,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
resize: "vertical",
fontFamily: "inherit",
height: 100,
} as const;
return (
<div style={{
position: "fixed",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000
}}>
<div style={{
background: "#0b1220",
padding: 20,
borderRadius: 8,
border: "1px solid #334155",
width: 400,
color: "white"
}}>
<h3 style={{ marginTop: 0 }}>Nội dung Submit</h3>
<textarea
value={submitContent}
onChange={(e) => setSubmitContent(e.target.value)}
placeholder="Nhập nội dung submit..."
style={textAreaStyle}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 15 }}>
<button onClick={handleCancelSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #334155", background: "transparent", color: "white" }}>Hủy</button>
<button onClick={handleConfirmSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "none", background: "#16a34a", color: "white", fontWeight: "bold" }}>Gửi Submit</button>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { Panel } from "./Panel";
import { ModeHint } from "./ModeHint";
type ToolsPanelProps = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
onUndo: () => void;
};
export function ToolsPanel({ mode, setMode, onUndo }: ToolsPanelProps) {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle");
} else {
setMode(newMode);
}
};
const modeButtonStyle = (btnMode: EditorMode) =>
({
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: mode === btnMode ? "#16a34a" : "#111827",
color: "white",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
minHeight: 34,
boxSizing: "border-box",
}) as const;
return (
<Panel title="Tools" defaultOpen>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
Select
</button>
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
Draw
</button>
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
Point
</button>
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
Line
</button>
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
Path
</button>
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
Circle
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
</div>
<ModeHint mode={mode} />
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
style={{
...modeButtonStyle("idle"),
background: "#111827",
}}
onClick={() => setMode("idle")}
title="Tắt tool hiện tại"
>
Idle
</button>
<button
style={{
...modeButtonStyle("idle"),
background: "#334155",
}}
onClick={onUndo}
title="Undo thao tác gần nhất"
>
Undo
</button>
</div>
</Panel>
);
}
@@ -0,0 +1,59 @@
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel";
type UndoListPanelProps = {
undoStack: UndoAction[];
};
export function UndoListPanel({ undoStack }: UndoListPanelProps) {
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
return (
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</Panel>
);
}
export function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
case "snapshot_entities":
case "snapshot_wikis":
case "snapshot_entity_wiki":
case "replay":
case "replays":
case "replay_session":
case "group":
return action.label;
default:
return "Tác vụ";
}
}
+465
View File
@@ -0,0 +1,465 @@
import maplibregl from "maplibre-gl";
export type MapImageOverlay = {
url: string;
name: string;
opacity: number;
aspectRatio: number;
coordinates: maplibregl.Coordinates;
};
const IMAGE_OVERLAY_SOURCE_ID = "uhm-image-overlay-source";
const IMAGE_OVERLAY_LAYER_ID = "uhm-image-overlay-layer";
const IMAGE_OVERLAY_CONTROL_SOURCE_ID = "uhm-image-overlay-control-source";
const IMAGE_OVERLAY_HANDLE_LAYER_ID = "uhm-image-overlay-handles";
const IMAGE_OVERLAY_CENTER_LAYER_ID = "uhm-image-overlay-center";
type OverlayControlAction = "move" | "resize";
type OverlayResizeEdge = "top" | "right" | "bottom" | "left";
type OverlayControlFeature = GeoJSON.Feature<GeoJSON.Point, {
action: OverlayControlAction;
edge?: OverlayResizeEdge;
}>;
export function applyImageOverlay(
map: maplibregl.Map,
overlay: MapImageOverlay | null | undefined
) {
if (!overlay) {
removeImageOverlay(map);
return;
}
const existingSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
if (existingSource) {
if (existingSource.url === overlay.url) {
existingSource.setCoordinates(overlay.coordinates);
} else {
existingSource.updateImage({
url: overlay.url,
coordinates: overlay.coordinates,
});
}
} else {
map.addSource(IMAGE_OVERLAY_SOURCE_ID, {
type: "image",
url: overlay.url,
coordinates: overlay.coordinates,
});
}
if (!map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_LAYER_ID,
type: "raster",
source: IMAGE_OVERLAY_SOURCE_ID,
paint: {
"raster-opacity": clampOpacity(overlay.opacity),
"raster-fade-duration": 0,
"raster-resampling": "linear",
},
});
} else {
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-opacity", clampOpacity(overlay.opacity));
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-fade-duration", 0);
}
// Không truyền beforeId để layer được đưa lên trên cùng, phục vụ trace khi vẽ.
map.moveLayer(IMAGE_OVERLAY_LAYER_ID);
applyImageOverlayControls(map, overlay);
}
export function removeImageOverlay(map: maplibregl.Map) {
removeImageOverlayControls(map);
if (map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_SOURCE_ID);
}
}
export function getViewportImageCoordinates(
map: maplibregl.Map,
aspectRatio: number
): maplibregl.Coordinates {
const canvas = map.getCanvas();
const canvasWidth = Math.max(canvas.clientWidth || canvas.width || 800, 1);
const canvasHeight = Math.max(canvas.clientHeight || canvas.height || 600, 1);
const safeAspectRatio = normalizeAspectRatio(aspectRatio);
let width = canvasWidth * 0.72;
let height = width / safeAspectRatio;
const maxHeight = canvasHeight * 0.72;
if (height > maxHeight) {
height = maxHeight;
width = height * safeAspectRatio;
}
return buildCoordinatesFromScreenBox(
map,
{ x: canvasWidth / 2, y: canvasHeight / 2 },
width,
height
);
}
export function moveImageOverlayCoordinatesByPixels(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
deltaX: number,
deltaY: number
): maplibregl.Coordinates {
return moveCoordinates(map, coordinates, new maplibregl.Point(deltaX, deltaY));
}
export function scaleImageOverlayCoordinatesByFactor(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
factor: number,
aspectRatio: number
): maplibregl.Coordinates {
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
const screenBox = getScreenBox(map, coordinates);
const minimumSize = 48;
const width = Math.max(screenBox.width * safeFactor, minimumSize);
const height = width / normalizeAspectRatio(aspectRatio);
return buildCoordinatesFromScreenBox(map, screenBox.center, width, height);
}
export function bindImageOverlayInteractions(
map: maplibregl.Map,
getOverlay: () => MapImageOverlay | null,
onChange: (overlay: MapImageOverlay) => void
) {
let rafId: number | null = null;
let pendingCoordinates: maplibregl.Coordinates | null = null;
let latestOverlay: MapImageOverlay | null = null;
let activeDrag: {
action: OverlayControlAction;
edge: OverlayResizeEdge | null;
startPoint: maplibregl.Point;
startCoordinates: maplibregl.Coordinates;
startBox: ScreenBox;
aspectRatio: number;
wasDragPanEnabled: boolean;
} | null = null;
const startDrag = (event: maplibregl.MapLayerMouseEvent) => {
if ((event.originalEvent as MouseEvent | undefined)?.button !== 2) return;
const overlay = getOverlay();
const feature = event.features?.[0] as OverlayControlFeature | undefined;
if (!overlay || !feature?.properties?.action) return;
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
activeDrag = {
action: feature.properties.action,
edge: feature.properties.edge || null,
startPoint: event.point,
startCoordinates: overlay.coordinates,
startBox: getScreenBox(map, overlay.coordinates),
aspectRatio: normalizeAspectRatio(overlay.aspectRatio),
wasDragPanEnabled: map.dragPan.isEnabled(),
};
latestOverlay = overlay;
map.dragPan.disable();
map.getCanvas().style.cursor = activeDrag.action === "move" ? "grabbing" : "nwse-resize";
};
const moveDrag = (event: maplibregl.MapMouseEvent) => {
if (!activeDrag) return;
const overlay = getOverlay();
if (!overlay) return;
event.preventDefault();
const nextCoordinates = activeDrag.action === "move"
? moveCoordinates(map, activeDrag.startCoordinates, event.point.sub(activeDrag.startPoint))
: resizeCoordinates(map, activeDrag.startBox, event.point, activeDrag.edge, activeDrag.aspectRatio);
latestOverlay = {
...overlay,
coordinates: nextCoordinates,
};
scheduleImageOverlayCoordinateUpdate(map, nextCoordinates);
};
const endDrag = () => {
if (!activeDrag) return;
const finishedDrag = activeDrag;
activeDrag = null;
flushImageOverlayCoordinateUpdate(map);
if (latestOverlay) {
onChange(latestOverlay);
latestOverlay = null;
}
if (finishedDrag.wasDragPanEnabled && !map.dragPan.isEnabled()) {
map.dragPan.enable();
}
map.getCanvas().style.cursor = "";
};
const preventContextMenu = (event: maplibregl.MapLayerMouseEvent) => {
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
};
map.on("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.on("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.on("mousemove", moveDrag);
map.on("mouseup", endDrag);
map.on("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.on("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
return () => {
endDrag();
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingCoordinates = null;
map.off("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.off("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.off("mousemove", moveDrag);
map.off("mouseup", endDrag);
map.off("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.off("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
};
function scheduleImageOverlayCoordinateUpdate(
targetMap: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
pendingCoordinates = coordinates;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
flushImageOverlayCoordinateUpdate(targetMap);
});
}
function flushImageOverlayCoordinateUpdate(targetMap: maplibregl.Map) {
if (!pendingCoordinates) return;
updateImageOverlayCoordinates(targetMap, pendingCoordinates);
pendingCoordinates = null;
}
}
export function getImageOverlayInteractiveLayerIds() {
return [IMAGE_OVERLAY_HANDLE_LAYER_ID, IMAGE_OVERLAY_CENTER_LAYER_ID];
}
function applyImageOverlayControls(map: maplibregl.Map, overlay: MapImageOverlay) {
const data = buildControlFeatureCollection(overlay.coordinates);
const existingSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (existingSource) {
existingSource.setData(data);
} else {
map.addSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID, {
type: "geojson",
data,
});
}
if (!map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_HANDLE_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "resize"],
paint: {
"circle-color": "#38bdf8",
"circle-radius": 7,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
if (!map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_CENTER_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "move"],
paint: {
"circle-color": "#fbbf24",
"circle-radius": 8,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
map.moveLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
map.moveLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
function updateImageOverlayCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
const imageSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
imageSource?.setCoordinates(coordinates);
const controlSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
controlSource?.setData(buildControlFeatureCollection(coordinates));
}
function removeImageOverlayControls(map: maplibregl.Map) {
if (map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
if (map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID);
}
}
function buildControlFeatureCollection(
coordinates: maplibregl.Coordinates
): GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]> {
const [topLeft, topRight, bottomRight, bottomLeft] = coordinates;
const center = averageCoordinates(coordinates);
return [
createControlFeature(center, { action: "move" }),
createControlFeature(midpoint(topLeft, topRight), { action: "resize", edge: "top" }),
createControlFeature(midpoint(topRight, bottomRight), { action: "resize", edge: "right" }),
createControlFeature(midpoint(bottomRight, bottomLeft), { action: "resize", edge: "bottom" }),
createControlFeature(midpoint(bottomLeft, topLeft), { action: "resize", edge: "left" }),
].reduce<GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]>>(
(collection, feature) => {
collection.features.push(feature);
return collection;
},
{ type: "FeatureCollection", features: [] }
);
}
function createControlFeature(
coordinates: [number, number],
properties: OverlayControlFeature["properties"]
): OverlayControlFeature {
return {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates,
},
};
}
type ScreenPoint = { x: number; y: number };
type ScreenBox = {
center: ScreenPoint;
width: number;
height: number;
};
function getScreenBox(map: maplibregl.Map, coordinates: maplibregl.Coordinates): ScreenBox {
const points = coordinates.map((coordinate) => map.project(coordinate));
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
return {
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
},
width: Math.max(maxX - minX, 40),
height: Math.max(maxY - minY, 40),
};
}
function moveCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
delta: maplibregl.Point
): maplibregl.Coordinates {
return coordinates.map((coordinate) => {
const point = map.project(coordinate);
return lngLatToCoordinate(map.unproject([point.x + delta.x, point.y + delta.y]));
}) as maplibregl.Coordinates;
}
function resizeCoordinates(
map: maplibregl.Map,
startBox: ScreenBox,
currentPoint: maplibregl.Point,
edge: OverlayResizeEdge | null,
aspectRatio: number
): maplibregl.Coordinates {
const minimumSize = 48;
let width = startBox.width;
let height = startBox.height;
if (edge === "left" || edge === "right") {
width = Math.max(Math.abs(currentPoint.x - startBox.center.x) * 2, minimumSize);
height = width / aspectRatio;
} else {
height = Math.max(Math.abs(currentPoint.y - startBox.center.y) * 2, minimumSize);
width = height * aspectRatio;
}
return buildCoordinatesFromScreenBox(map, startBox.center, width, height);
}
function buildCoordinatesFromScreenBox(
map: maplibregl.Map,
center: ScreenPoint,
width: number,
height: number
): maplibregl.Coordinates {
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y + halfHeight])),
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y + halfHeight])),
];
}
function averageCoordinates(coordinates: maplibregl.Coordinates): [number, number] {
const total = coordinates.reduce(
(sum, coordinate) => ({
lng: sum.lng + coordinate[0],
lat: sum.lat + coordinate[1],
}),
{ lng: 0, lat: 0 }
);
return [total.lng / coordinates.length, total.lat / coordinates.length];
}
function midpoint(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
function lngLatToCoordinate(lngLat: maplibregl.LngLat): [number, number] {
return [lngLat.lng, lngLat.lat];
}
function normalizeAspectRatio(value: number) {
if (!Number.isFinite(value) || value <= 0) return 1;
return value;
}
function clampOpacity(value: number) {
if (!Number.isFinite(value)) return 0.55;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
export type MapHoverPopupContent = {
key?: string;
rows: Array<{
title: string;
titleTone?: "danger" | "default";
isGroupHeader?: boolean;
description?: string | null;
titleTimeRange?: string | null;
titleTimeRangeTone?: "success" | "muted";
separatorBefore?: boolean;
quote?: string | null;
quoteTone?: "danger" | "default";
onClick?: () => void;
}>;
};
type UseMapHoverPopupProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
enabled: boolean;
renderDraftRef: React.MutableRefObject<FeatureCollection>;
getContentRef: React.MutableRefObject<((feature: Feature) => MapHoverPopupContent | null) | undefined>;
onHoverFeatureChangeRef?: React.MutableRefObject<((feature: Feature | null) => void) | undefined>;
};
export function useMapHoverPopup({
mapRef,
enabled,
renderDraftRef,
getContentRef,
onHoverFeatureChangeRef,
}: UseMapHoverPopupProps) {
const enabledRef = useRef(enabled);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
const map = mapRef.current;
if (!map || !enabled) return;
// Disable hover popup if the device has no hover capability (mobile/tablet)
const hasHoverSupport = window.matchMedia("(hover: hover)").matches;
if (!hasHoverSupport) return;
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
offset: 12,
className: "uhm-map-hover-popup",
});
let hoveredKey: string | null = null;
let frameId: number | null = null;
let pendingEvent: maplibregl.MapMouseEvent | null = null;
let lastMapMouseEvent: maplibregl.MapMouseEvent | null = null;
let currentContent: MapHoverPopupContent | null = null;
let selectedRowIndex = 0;
let selectionVisible = false;
let selectionDirection: "next" | "prev" | null = null;
let lastSelectedRowClick: (() => void) | null = null;
let lastSelectedRowAt = 0;
let hoverLayerIds = getHoverLayerIds(map);
let activeFeatureId: string | null = null;
let featureLookupDraft: FeatureCollection | null = null;
let featureById = new Map<string, Feature>();
const refreshHoverLayerIds = () => {
hoverLayerIds = getHoverLayerIds(map);
};
const getSourceFeatureById = (id: string) => {
const draft = renderDraftRef.current;
if (draft !== featureLookupDraft) {
featureLookupDraft = draft;
featureById = new Map(draft.features.map((item) => [String(item.properties.id), item]));
}
return featureById.get(id) || null;
};
const removePopup = () => {
hoveredKey = null;
currentContent = null;
selectedRowIndex = 0;
selectionVisible = false;
if (activeFeatureId !== null) {
activeFeatureId = null;
onHoverFeatureChangeRef?.current?.(null);
}
popup.remove();
};
const getCurrentRows = () => getSelectableRows(currentContent);
const syncSelectedRow = () => {
const rows = getCurrentRows();
if (!rows.length) return;
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
lastSelectedRowClick = rows[selectedRowIndex]?.onClick || null;
lastSelectedRowAt = Date.now();
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
};
const cyclePopupRow = (direction: "next" | "prev") => {
const rows = getCurrentRows();
if (!rows.length) return;
const currentIndex = wrapIndex(selectedRowIndex, rows.length);
const nextIndex = direction === "prev"
? Math.max(0, currentIndex - 1)
: Math.min(rows.length - 1, currentIndex + 1);
selectionDirection = direction;
if (nextIndex === currentIndex) {
selectedRowIndex = currentIndex;
syncSelectedRow();
return;
}
selectedRowIndex = nextIndex;
syncSelectedRow();
};
const onWheel = (event: WheelEvent) => {
if (!event.shiftKey) return;
if (isEditableEventTarget(event.target)) return;
const rows = getCurrentRows();
if (!rows.length) return;
const target = event.target instanceof HTMLElement ? event.target : null;
const insidePopup = Boolean(target?.closest(".uhm-map-hover-popup"));
const insideMap = target ? map.getContainer().contains(target) : false;
if (!insidePopup && !insideMap) return;
const direction = event.deltaY > 0 ? "next" : event.deltaY < 0 ? "prev" : null;
if (!direction) return;
event.preventDefault();
event.stopPropagation();
selectionVisible = true;
cyclePopupRow(direction);
};
const onCommitPopupRow = () => {
const rows = getCurrentRows();
const selectedRowClick = rows.length
? rows[wrapIndex(selectedRowIndex, rows.length)]?.onClick
: (Date.now() - lastSelectedRowAt < 1200 ? lastSelectedRowClick : null);
selectedRowClick?.();
if (rows.length || selectedRowClick) {
removePopup();
}
};
const requestPopupUpdateFromLastMouseEvent = () => {
if (!lastMapMouseEvent || !enabledRef.current) return;
pendingEvent = lastMapMouseEvent;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(updatePopup);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") {
if (event.repeat) return;
if (isEditableEventTarget(event.target)) return;
selectionVisible = true;
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
requestPopupUpdateFromLastMouseEvent();
return;
}
if (event.key !== "Enter") return;
if (isEditableEventTarget(event.target)) return;
if (!getCurrentRows().length) return;
event.preventDefault();
event.stopPropagation();
onCommitPopupRow();
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key !== "Shift") return;
if (isEditableEventTarget(event.target)) return;
if (!getCurrentRows().length && lastMapMouseEvent) {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
pendingEvent = pendingEvent || lastMapMouseEvent;
updatePopup();
}
event.preventDefault();
event.stopPropagation();
onCommitPopupRow();
};
const updatePopup = () => {
frameId = null;
const event = pendingEvent;
pendingEvent = null;
if (!event || !enabledRef.current) {
removePopup();
return;
}
if (!hoverLayerIds.length) {
removePopup();
return;
}
let features: maplibregl.MapGeoJSONFeature[];
try {
features = map.queryRenderedFeatures(event.point, { layers: hoverLayerIds }) as maplibregl.MapGeoJSONFeature[];
} catch {
refreshHoverLayerIds();
removePopup();
return;
}
if (!features.length) {
removePopup();
return;
}
const renderedFeature = pickPreferredFeature(features);
const rawId = renderedFeature.id ?? renderedFeature.properties?.id;
if (rawId === undefined || rawId === null) {
removePopup();
return;
}
const id = String(rawId);
const sourceFeature = getSourceFeatureById(id);
if (!sourceFeature) {
removePopup();
return;
}
if (id !== activeFeatureId) {
activeFeatureId = id;
onHoverFeatureChangeRef?.current?.(sourceFeature);
}
const content = getContentRef.current?.(sourceFeature) || null;
if (!content?.rows?.some((row) => row.title.trim())) {
removePopup();
return;
}
const contentKey = buildContentKey(id, content);
const contentChanged = contentKey !== hoveredKey;
const shouldStylePopup = contentChanged || !popup.isOpen();
if (contentKey !== hoveredKey) {
hoveredKey = contentKey;
currentContent = content;
selectedRowIndex = 0;
if (!selectionVisible) {
selectionVisible = Boolean(event.originalEvent?.shiftKey);
}
popup.setDOMContent(buildPopupNode(content, selectedRowIndex, selectionVisible));
}
popup.setLngLat(event.lngLat);
if (!popup.isOpen()) {
popup.addTo(map);
}
if (shouldStylePopup) {
stylePopupChrome(popup);
}
if (contentChanged) {
syncSelectedRow();
}
};
const onMouseMove = (event: maplibregl.MapMouseEvent) => {
lastMapMouseEvent = event;
pendingEvent = event;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(updatePopup);
};
const onMouseOut = () => {
lastMapMouseEvent = null;
pendingEvent = null;
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
removePopup();
};
map.on("mousemove", onMouseMove);
map.on("mouseout", onMouseOut);
map.on("dragstart", removePopup);
map.on("zoomstart", removePopup);
map.on("styledata", refreshHoverLayerIds);
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
window.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener("keyup", onKeyUp, { capture: true });
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
map.off("mousemove", onMouseMove);
map.off("mouseout", onMouseOut);
map.off("dragstart", removePopup);
map.off("zoomstart", removePopup);
map.off("styledata", refreshHoverLayerIds);
window.removeEventListener("wheel", onWheel, { capture: true });
window.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener("keyup", onKeyUp, { capture: true });
popup.remove();
};
}, [enabled, getContentRef, mapRef, onHoverFeatureChangeRef, renderDraftRef]);
}
function getHoverLayerIds(map: maplibregl.Map): string[] {
const style = map.getStyle();
if (!style?.layers) return [];
return style.layers
.filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCE_IDS.includes(layer.source as (typeof FEATURE_STATE_SOURCE_IDS)[number])
)
.map((layer) => layer.id);
}
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
let best = features[0];
let bestPriority = featureSelectPriority(best);
for (let index = 1; index < features.length; index += 1) {
const candidate = features[index];
const priority = featureSelectPriority(candidate);
if (priority > bestPriority) {
best = candidate;
bestPriority = priority;
}
}
return best;
}
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
const geometryType = feature.geometry?.type;
const source = typeof feature.source === "string" ? feature.source : "";
if (layerId.endsWith("-hit")) return 400;
if (source === "path-arrow-shapes") return 300;
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
return 0;
}
function buildPopupNode(content: MapHoverPopupContent, selectedRowIndex: number, selectionVisible: boolean): HTMLElement {
ensureHoverPopupScrollbarStyle();
const root = document.createElement("div");
root.className = "uhm-map-hover-popup-body";
root.dataset.hoverPopupScrollRoot = "true";
root.style.width = "320px";
root.style.maxWidth = "calc(100vw - 2rem)";
root.style.maxHeight = "300px";
root.style.overflowY = "auto";
root.style.padding = "12px";
root.style.border = "1px solid rgba(255, 255, 255, 0.10)";
root.style.borderRadius = "0px";
root.style.background = "rgba(2, 6, 23, 0.95)";
root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
root.style.backdropFilter = "blur(8px)";
root.style.color = "#e2e8f0";
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gap = "5px";
root.appendChild(grid);
const rows = normalizeRows(content);
let selectableRowIndex = 0;
rows.forEach((row) => {
const titleText = row.title.trim();
if (row.separatorBefore) {
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.margin = "2px 0";
separator.style.background = "rgba(255, 255, 255, 0.16)";
separator.style.pointerEvents = "none";
grid.appendChild(separator);
}
const isGroupHeader = Boolean(row.isGroupHeader);
const rowSelectionIndex = isGroupHeader ? null : selectableRowIndex++;
const card: HTMLButtonElement | HTMLDivElement = row.onClick
? document.createElement("button")
: document.createElement("div");
if (row.onClick) {
(card as HTMLButtonElement).type = "button";
card.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
row.onClick?.();
};
}
card.style.width = isGroupHeader ? "100%" : "calc(100% - 18px)";
card.style.marginLeft = isGroupHeader ? "0" : "18px";
card.style.border = isGroupHeader ? "0" : "1px solid transparent";
card.style.borderRadius = "0px";
card.style.background = "transparent";
card.style.padding = isGroupHeader ? "8px 2px 3px" : "7px 9px";
card.style.textAlign = "left";
card.style.font = "inherit";
card.style.cursor = row.onClick ? "pointer" : "default";
card.style.display = "block";
card.style.outline = "none";
card.style.appearance = "none";
card.style.webkitAppearance = "none";
card.style.transition = "border-color 140ms ease, background 140ms ease";
if (rowSelectionIndex !== null) {
card.dataset.hoverPopupRowIndex = String(rowSelectionIndex);
}
if (isGroupHeader) {
card.dataset.hoverPopupGroupHeader = "true";
}
if (row.onClick) {
card.onmouseenter = () => {
if (card.dataset.hoverPopupSelected === "true") return;
card.style.borderColor = "transparent";
card.style.background = "rgba(14, 165, 233, 0.08)";
};
card.onmouseleave = () => {
if (card.dataset.hoverPopupSelected === "true") {
applyPopupRowStyle(card, true);
return;
}
card.style.borderColor = "transparent";
card.style.background = "transparent";
};
}
const title = document.createElement("div");
title.style.fontSize = isGroupHeader ? "14px" : "13px";
title.style.fontWeight = isGroupHeader ? "800" : "650";
title.style.lineHeight = isGroupHeader ? "20px" : "18px";
title.style.color = row.titleTone === "danger" ? "#f87171" : (isGroupHeader ? "#ffffff" : "#e5edf7");
title.style.overflow = "hidden";
title.style.textOverflow = "ellipsis";
title.style.whiteSpace = "nowrap";
const descriptionText = row.description?.trim();
if (row.titleTimeRange?.trim()) {
const nameSpan = document.createElement("span");
nameSpan.textContent = titleText;
title.appendChild(nameSpan);
const rangeSpan = document.createElement("span");
rangeSpan.textContent = ` (${row.titleTimeRange.trim()})`;
rangeSpan.style.color = row.titleTimeRangeTone === "success" ? "#34d399" : "#94a3b8";
rangeSpan.style.fontWeight = "700";
title.appendChild(rangeSpan);
} else {
title.textContent = titleText;
}
card.appendChild(title);
if (isGroupHeader && descriptionText) {
const descriptionWrap = document.createElement("div");
descriptionWrap.className = "uhm-map-hover-popup-description-marquee";
descriptionWrap.title = descriptionText;
const descriptionSpan = document.createElement("span");
descriptionSpan.textContent = descriptionText;
descriptionWrap.appendChild(descriptionSpan);
card.appendChild(descriptionWrap);
}
const quoteText = row.quote?.trim();
if (quoteText) {
const quote = document.createElement("div");
quote.textContent = quoteText;
quote.style.marginTop = "5px";
quote.style.paddingLeft = "8px";
quote.style.paddingRight = "4px";
quote.style.borderLeft = `2px solid ${row.quoteTone === "danger" ? "rgba(248, 113, 113, 0.58)" : "rgba(56, 189, 248, 0.34)"}`;
quote.style.fontSize = "13px";
quote.style.fontStyle = "italic";
quote.style.lineHeight = "18px";
quote.style.color = row.quoteTone === "danger" ? "#f87171" : "#b8c4d4";
quote.style.display = "-webkit-box";
quote.style.webkitLineClamp = "4";
quote.style.webkitBoxOrient = "vertical";
quote.style.overflow = "hidden";
quote.style.whiteSpace = "normal";
card.appendChild(quote);
}
grid.appendChild(card);
});
updatePopupNodeRowSelection(root, selectedRowIndex, selectionVisible, null);
return root;
}
function buildContentKey(featureId: string, content: MapHoverPopupContent): string {
if (content.key) return `${featureId}:${content.key}`;
return `${featureId}:${content.rows
.map((row) => [
row.separatorBefore ? "sep" : "",
row.isGroupHeader ? "group" : "",
row.title,
row.titleTone || "",
row.description || "",
row.titleTimeRange || "",
row.titleTimeRangeTone || "",
row.quote || "",
].join(":"))
.join("|")}`;
}
function ensureHoverPopupScrollbarStyle() {
const styleId = "uhm-map-hover-popup-scrollbar-style";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
.uhm-map-hover-popup-body {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-map-hover-popup-body::-webkit-scrollbar {
width: 10px;
}
.uhm-map-hover-popup-body::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid rgba(2, 6, 23, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(125, 211, 252, 0.96), rgba(56, 189, 248, 0.72));
}
.uhm-map-hover-popup-body button,
.uhm-map-hover-popup-body button:focus,
.uhm-map-hover-popup-body button:focus-visible {
outline: none !important;
box-shadow: none;
}
.uhm-map-hover-popup-description-marquee {
display: block;
width: 100%;
margin-top: 2px;
overflow: hidden;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
}
.uhm-map-hover-popup-description-marquee > span {
display: inline-block;
min-width: 100%;
transform: translateX(0);
}
.uhm-map-hover-popup-description-marquee:hover > span {
animation: uhm-hover-popup-marquee 8s linear infinite alternate;
}
@keyframes uhm-hover-popup-marquee {
from { transform: translateX(0); }
to { transform: translateX(calc(-100% + 100px)); }
}
`;
document.head.appendChild(style);
}
function isEditableEventTarget(target: EventTarget | null): boolean {
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
}
function normalizeRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
return (content?.rows || []).filter((row) => row.title.trim());
}
function getSelectableRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
return normalizeRows(content).filter((row) => !row.isGroupHeader);
}
function wrapIndex(index: number, length: number): number {
if (length <= 0) return 0;
return ((index % length) + length) % length;
}
function updatePopupRowSelection(
popup: maplibregl.Popup,
selectedRowIndex: number,
selectionVisible: boolean,
selectionDirection: "next" | "prev" | null
) {
updatePopupNodeRowSelection(popup.getElement() || null, selectedRowIndex, selectionVisible, selectionDirection);
}
function updatePopupNodeRowSelection(
root: HTMLElement | null,
selectedRowIndex: number,
selectionVisible: boolean,
selectionDirection: "next" | "prev" | null
) {
if (!root) return;
const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-hover-popup-row-index]"));
let selectedCard: HTMLElement | null = null;
for (const card of cards) {
const rowIndex = Number(card.dataset.hoverPopupRowIndex);
const selected = selectionVisible && rowIndex === selectedRowIndex;
card.dataset.hoverPopupSelected = selected ? "true" : "false";
applyPopupRowStyle(card, selected);
if (selected) {
selectedCard = card;
}
}
if (selectedCard) {
ensurePopupRowVisible(selectedCard, selectionDirection);
window.requestAnimationFrame(() => ensurePopupRowVisible(selectedCard, selectionDirection));
}
}
function applyPopupRowStyle(card: HTMLElement, selected: boolean) {
card.style.borderColor = "transparent";
card.style.background = selected ? "rgba(14, 165, 233, 0.11)" : "transparent";
card.style.boxShadow = selected ? "inset 2px 0 0 rgba(56, 189, 248, 0.95)" : "none";
}
function ensurePopupRowVisible(card: HTMLElement, direction: "next" | "prev" | null) {
const scrollRoot = card.closest<HTMLElement>("[data-hover-popup-scroll-root='true']");
if (!scrollRoot) return;
const padding = 8;
const groupHeader = direction === "prev" ? findPreviousGroupHeader(card) : null;
const cardTop = card.offsetTop;
const cardBottom = cardTop + card.offsetHeight;
const targetTop = groupHeader?.offsetTop ?? cardTop;
const visibleTop = scrollRoot.scrollTop + padding;
const visibleBottom = scrollRoot.scrollTop + scrollRoot.clientHeight - padding;
if (targetTop < visibleTop) {
scrollRoot.scrollTop = Math.max(0, targetTop - padding);
return;
}
if (cardBottom > visibleBottom) {
scrollRoot.scrollTop = cardBottom - scrollRoot.clientHeight + padding;
}
}
function findPreviousGroupHeader(card: HTMLElement): HTMLElement | null {
let current = card.previousElementSibling;
while (current) {
if (current instanceof HTMLElement && current.dataset.hoverPopupGroupHeader === "true") {
return current;
}
current = current.previousElementSibling;
}
return null;
}
function stylePopupChrome(popup: maplibregl.Popup) {
const element = popup.getElement();
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
if (content) {
content.style.padding = "0";
content.style.borderRadius = "0px";
content.style.background = "transparent";
content.style.boxShadow = "none";
}
for (const tip of Array.from(element.querySelectorAll(".maplibregl-popup-tip")) as HTMLElement[]) {
tip.style.display = "none";
}
}
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState, useCallback } from "react";
import maplibregl from "maplibre-gl";
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
import { clampNumber, roundZoom } from "./mapUtils";
import { getBaseMapStyle } from "./useMapLayers";
import { unregisterMapFromIconUpdates } from "@/uhm/lib/map/styles/geotypeLayers";
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
const MAP_VIEWPORT_STORAGE_KEY = "uhm:mapViewport";
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
}
export function useMapInstance() {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
} catch {
return false;
}
});
const [isMapLoaded, setIsMapLoaded] = useState(false);
const geolocationCenteredRef = useRef(false);
// Ref khóa sync zoom từ MapLibre trong lúc user kéo slider để tránh value bị animate ghi ngược.
const isZoomSliderDraggingRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
MAP_PROJECTION_STORAGE_KEY,
isGlobeProjection ? "globe" : "mercator"
);
} catch {
// ignore
}
}, [isGlobeProjection]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
try {
let initialCenter: [number, number] = [0, 20];
let initialZoom = 2;
try {
const saved = window.localStorage.getItem(MAP_VIEWPORT_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Number.isFinite(parsed.lng) && Number.isFinite(parsed.lat) && Number.isFinite(parsed.zoom)) {
initialCenter = [parsed.lng, parsed.lat];
initialZoom = parsed.zoom;
}
}
} catch {
// ignore
}
const map = new maplibregl.Map({
container,
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
style: getBaseMapStyle(),
center: initialCenter,
zoom: initialZoom,
});
mapRef.current = map;
const saveViewport = () => {
const currentMap = mapRef.current;
if (!currentMap) return;
try {
const center = currentMap.getCenter();
const zoom = currentMap.getZoom();
window.localStorage.setItem(
MAP_VIEWPORT_STORAGE_KEY,
JSON.stringify({ lng: center.lng, lat: center.lat, zoom })
);
} catch {
// ignore
}
};
let throttleTimeout: ReturnType<typeof setTimeout> | null = null;
const syncZoomLevelImmediate = () => {
if (isZoomSliderDraggingRef.current) return;
const currentMap = mapRef.current;
if (!currentMap) return;
const next = roundZoom(currentMap.getZoom());
setZoomLevel((prev) => (prev === next ? prev : next));
};
const syncZoomLevelThrottled = () => {
if (isZoomSliderDraggingRef.current) return;
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
throttleTimeout = null;
syncZoomLevelImmediate();
}, 150);
};
map.on("load", () => {
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
syncZoomLevelImmediate();
map.on("zoom", syncZoomLevelThrottled);
map.on("zoomend", syncZoomLevelImmediate);
map.on("moveend", saveViewport);
setIsMapLoaded(true);
});
return () => {
if (throttleTimeout) {
clearTimeout(throttleTimeout);
}
map.off("zoom", syncZoomLevelThrottled);
map.off("zoomend", syncZoomLevelImmediate);
map.off("moveend", saveViewport);
setIsMapLoaded(false);
if (mapRef.current === map) {
mapRef.current = null;
}
unregisterMapFromIconUpdates(map);
map.remove();
};
} catch (err) {
console.error("Map initialization failed", err);
const message = err instanceof Error ? err.message : "Map initialization failed.";
window.setTimeout(() => setFatalInitError(message), 0);
}
}, []);
// Sync Map Projection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (mapRef.current !== map) return;
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
applyMapProjection(map, isGlobeProjection);
};
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
apply();
return;
}
map.once("load", apply);
map.once("style.load", apply);
return () => {
map.off("load", apply);
map.off("style.load", apply);
};
}, [isGlobeProjection]);
const handleZoomByStep = useCallback((delta: number) => {
const map = mapRef.current;
if (!map) return;
setZoomLevel((prev) => {
const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 120 });
return next;
});
}, [zoomBounds]);
const handleZoomSliderChange = useCallback((nextRaw: number) => {
const map = mapRef.current;
if (!map || !Number.isFinite(nextRaw)) return;
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
// Slider cần phản hồi trực tiếp theo pointer; easeTo liên tục sẽ làm thumb bị nhảy ngược.
map.jumpTo({ zoom: next });
setZoomLevel(next);
}, [zoomBounds]);
const beginZoomSliderDrag = useCallback(() => {
isZoomSliderDraggingRef.current = true;
}, []);
const endZoomSliderDrag = useCallback(() => {
const map = mapRef.current;
isZoomSliderDraggingRef.current = false;
if (!map) return;
setZoomLevel(roundZoom(map.getZoom()));
}, []);
const getViewState = useCallback(() => {
const map = mapRef.current;
if (!map) return null;
const center = map.getCenter();
const projection = map.getProjection();
return {
center: { lng: center.lng, lat: center.lat },
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
projection: String(projection?.type || "mercator"),
};
}, []);
return {
mapRef,
containerRef,
fatalInitError,
setFatalInitError,
zoomLevel,
zoomBounds,
isGlobeProjection,
setIsGlobeProjection,
isMapLoaded,
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState,
};
}
+392
View File
@@ -0,0 +1,392 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine";
import { initSelect } from "@/uhm/lib/map/engines/selectingEngine";
import { initPoint } from "@/uhm/lib/map/engines/pointEngine";
import { initLine } from "@/uhm/lib/map/engines/lineEngine";
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
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 } from "./mapUtils";
import type { MapFeaturePayload } from "../Map";
type EngineBinding = {
cleanup: () => void;
cancel?: () => void;
clearSelection?: (skipNotify?: boolean) => void;
syncSelection?: (ids: (string | number)[]) => void;
};
type UseMapInteractionProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
mode: EditorMode;
modeRef: React.MutableRefObject<EditorMode>;
// Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by
// replay/timeline state, so do not treat it as the canonical commit/edit draft.
renderDraftRef: React.MutableRefObject<FeatureCollection>;
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
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>;
onFeatureClickRef: React.MutableRefObject<((payload: MapFeaturePayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>;
onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
allowFeatureSelection?: boolean;
};
export function useMapInteraction({
mapRef,
mode,
modeRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onHideRef,
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection = true,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
const previousModeRef = useRef<EditorMode>(mode);
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
const allowGeometryEditingRef = useRef(allowGeometryEditing);
const allowFeatureSelectionRef = useRef(allowFeatureSelection);
useEffect(() => {
allowGeometryEditingRef.current = allowGeometryEditing;
}, [allowGeometryEditing]);
useEffect(() => {
allowFeatureSelectionRef.current = allowFeatureSelection;
}, [allowFeatureSelection]);
useEffect(() => {
if (!editingEngineRef.current) {
editingEngineRef.current = createEditingEngine({
mapRef,
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
});
}
}, [mapRef, onUpdateRef]);
useEffect(() => {
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
engineBindingsRef.current.select?.clearSelection?.(false);
}
}, [mode, selectedFeatureIds]);
useEffect(() => {
const selectEngine = engineBindingsRef.current.select;
if (selectEngine?.syncSelection) {
selectEngine.syncSelection(selectedFeatureIds);
}
}, [selectedFeatureIds]);
useEffect(() => {
const previousMode = previousModeRef.current;
if (previousMode !== mode) {
engineBindingsRef.current[previousMode]?.cancel?.();
previousModeRef.current = mode;
}
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "draw") {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-path") {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-circle") {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
}, [mode, mapRef]);
const setupMapInteractions = (map: maplibregl.Map) => {
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
const drawingEngine = initDrawing(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "country",
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const selectEngine = initSelect(
map,
() => modeRef.current,
(id: string | number | (string | number)[]) => {
editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id);
},
(feature) => {
const rawId = feature.properties?.id ?? feature.id;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
editingEngineRef.current?.beginEditing(
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
);
},
(id: string | number) => {
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
onCreateRef.current?.(nextFeature);
},
(id: string | number) => {
onHideRef.current?.(id);
onSelectFeatureIdsRef.current?.([]);
},
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current),
(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,
});
},
(feature) => {
if (!onAddFeatureToProjectRef?.current) return;
const rawId = feature.properties?.id ?? feature.id;
if (rawId === undefined || rawId === null) return;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
if (!originalFeature) return;
onAddFeatureToProjectRef.current?.(originalFeature);
},
(id) => {
if (!onAddFeatureToProjectRef?.current) return true;
const localIds = localFeatureIdsRef?.current;
if (!Array.isArray(localIds)) return true;
return localIds.some((localId) => String(localId) === String(id));
},
() => allowFeatureSelectionRef.current,
() => allowGeometryEditingRef.current
);
const cleanupPoint = initPoint(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "city",
geometry_preset: "point",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const lineEngine = initLine(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "defense_line",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const pathEngine = initPath(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "attack_route",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const circleEngine = initCircle(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "war",
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
preview: selectEngine,
replay: selectEngine,
replay_preview: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
};
mapCleanupFnsRef.current.push(
circleEngine.cleanup,
pathEngine.cleanup,
lineEngine.cleanup,
cleanupPoint,
selectEngine.cleanup,
drawingEngine.cleanup
);
const editCleanup = editingEngineRef.current?.bindEditEvents(map);
if (editCleanup) {
mapCleanupFnsRef.current.push(editCleanup);
}
};
const cleanupMapInteractions = () => {
for (const cleanupFn of mapCleanupFnsRef.current) {
cleanupFn();
}
mapCleanupFnsRef.current = [];
engineBindingsRef.current = {};
};
return {
editingEngineRef,
setupMapInteractions,
cleanupMapInteractions,
};
}
type MapWithRenderDraftRef = maplibregl.Map & {
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
};
function buildDuplicatedFeatureShapeOnly(
feature: FeatureCollection["features"][number]
): FeatureCollection["features"][number] {
const geometry = cloneGeometry(feature.geometry);
return {
type: "Feature",
properties: {
id: buildClientFeatureId(),
type: feature.properties.type ?? null,
geometry_preset: feature.properties.geometry_preset ?? null,
entity_id: null,
entity_ids: [],
entity_name: null,
entity_names: [],
bound_with: null,
},
geometry,
};
}
function cloneGeometry(geometry: Geometry): Geometry {
if (typeof structuredClone === "function") {
return structuredClone(geometry);
}
return JSON.parse(JSON.stringify(geometry)) as Geometry;
}
+289
View File
@@ -0,0 +1,289 @@
import maplibregl from "maplibre-gl";
import { GOONG_GLYPHS_PROXY_URL } from "@/uhm/api/config";
import { getGoongBackgroundOverlayBundle } from "@/uhm/api/tiles";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import {
applyBackgroundLayerVisibility,
ensurePathArrowIcon,
} from "./mapUtils";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
export function getBaseMapStyle(): maplibregl.StyleSpecification {
return {
version: 8,
glyphs: GOONG_GLYPHS_PROXY_URL,
sources: {},
layers: [
{
id: "background",
type: "background",
paint: {
"background-color": "#0b1220",
},
},
],
};
}
export function setupMapLayers(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility
) {
applyBackgroundLayerVisibility(map, backgroundVisibility);
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => {
console.error("Failed to load proxied background overlay bundle.", error);
});
const hasPathArrowIcon = ensurePathArrowIcon(map);
// preview (drawing)
map.addSource("draw-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-preview-fill",
type: "fill",
source: "draw-preview",
filter: ["==", ["get", "type"], "fill"],
paint: {
"fill-color": "#22c55e",
"fill-opacity": 0.4,
},
});
map.addLayer({
id: "draw-preview-line",
type: "line",
source: "draw-preview",
filter: ["!=", ["get", "type"], "fill"],
paint: {
"line-color": "#16a34a",
"line-width": 2,
},
});
map.addSource("draw-circle-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-circle-preview-fill",
type: "fill",
source: "draw-circle-preview",
paint: {
"fill-color": "#0ea5e9",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "draw-circle-preview-line",
type: "line",
source: "draw-circle-preview",
paint: {
"line-color": "#0284c7",
"line-width": 2,
"line-opacity": 0.95,
},
});
map.addSource("draw-line-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-line-preview-line",
type: "line",
source: "draw-line-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
map.addSource("draw-path-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-path-preview-line",
type: "line",
source: "draw-path-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
if (hasPathArrowIcon) {
map.addLayer({
id: "draw-path-preview-arrows",
type: "symbol",
source: "draw-path-preview",
layout: {
"symbol-placement": "line",
"symbol-spacing": 56,
"icon-image": PATH_ARROW_ICON_ID,
"icon-size": 0.45,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
}
// data
map.addSource("countries", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(PATH_ARROW_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
map.addSource("places", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(POLYGON_LABEL_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
ensurePointGeotypeIcons(map);
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
for (const layer of geotypeLayers) {
map.addLayer(layer);
}
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries");
for (const layer of geotypeLabelLayers) {
map.addLayer(layer);
}
// editing overlays
map.addSource("edit-shape", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addSource("edit-handles", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
// Glowing halo under the edit shape line
map.addLayer({
id: "edit-shape-glow",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 8,
"line-opacity": 0.35,
"line-blur": 1.5,
},
});
map.addLayer({
id: "edit-shape-line",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
},
});
// Glowing halo under the edit handles
map.addLayer({
id: "edit-handles-glow",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"unknown", "#22c55e",
"#3b82f6" // default none
],
"circle-radius": 22,
"circle-opacity": 0.35,
"circle-blur": 0.85,
},
});
map.addLayer({
id: "edit-handles-circle",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"unknown", "#22c55e",
"#3b82f6" // default none
],
"circle-radius": 12,
"circle-stroke-color": [
"match",
["get", "status"],
"delete", "#7f1d1d",
"vertex", "#14532d",
"edge", "#713f12",
"unknown", "#14532d",
"#0f172a" // default none
],
"circle-stroke-width": 3,
},
});
}
async function replaceBackgroundLayersWithGoong(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility
) {
const bundle = await getGoongBackgroundOverlayBundle();
if (!bundle || map.getLayer("goong-country-labels-0")) {
return;
}
for (const [sourceId, source] of Object.entries(bundle.sources)) {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, source);
}
}
const insertBeforeId = map.getLayer("draw-preview-fill")
? "draw-preview-fill"
: undefined;
for (const layer of bundle.layers) {
if (map.getLayer(layer.id)) continue;
map.addLayer(layer, insertBeforeId);
}
applyBackgroundLayerVisibility(map, backgroundVisibility);
}
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import {
applyBackgroundLayerVisibility,
buildPolygonLabelFeatureCollection,
buildPathArrowFeatureCollection,
decorateLineFeaturesWithLabels,
decoratePointFeaturesWithLabels,
filterDraftByBinding,
filterDraftByGeometryVisibility,
fitMapToFeatureCollection,
setSelectedFeatureState,
splitDraftFeatures,
decorateFeaturesWithEntityColors,
} from "./mapUtils";
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
// Already-filtered FeatureCollection that should be written to MapLibre sources.
// Timeline/replay filters must be applied before this hook receives it.
renderDraft: FeatureCollection;
// Lookup-only context for labels. It may contain geometries that are not rendered.
// Never use it to decide which geometries appear on the map.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
imageOverlay?: MapImageOverlay | null;
allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>;
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
isPreviewMode?: boolean;
};
export function useMapSync({
mapRef,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode,
}: UseMapSyncProps) {
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
const isPreviewModeRef = useRef(isPreviewMode);
useEffect(() => {
renderDraftRef.current = renderDraft;
}, [renderDraft]);
useEffect(() => {
labelContextDraftRef.current = labelContextDraft;
}, [labelContextDraft]);
useEffect(() => {
labelTimelineYearRef.current = labelTimelineYear;
}, [labelTimelineYear]);
useEffect(() => {
backgroundVisibilityRef.current = backgroundVisibility;
}, [backgroundVisibility]);
useEffect(() => {
geometryVisibilityRef.current = geometryVisibility;
}, [geometryVisibility]);
useEffect(() => {
selectedFeatureIdsRef.current = selectedFeatureIds;
}, [selectedFeatureIds]);
useEffect(() => {
applyGeometryBindingFilterRef.current = applyGeometryBindingFilter;
}, [applyGeometryBindingFilter]);
useEffect(() => {
fitToDraftBoundsRef.current = fitToDraftBounds;
}, [fitToDraftBounds]);
useEffect(() => {
imageOverlayRef.current = imageOverlay || null;
}, [imageOverlay]);
useEffect(() => {
focusFeatureCollectionRef.current = focusFeatureCollection;
}, [focusFeatureCollection]);
useEffect(() => {
focusPaddingRef.current = focusPadding;
}, [focusPadding]);
useEffect(() => {
isPreviewModeRef.current = isPreviewMode;
}, [isPreviewMode]);
const fitBoundsAppliedRef = useRef(false);
const lastCountriesStrRef = useRef("");
const lastPlacesStrRef = useRef("");
const lastPolygonLabelStrRef = useRef("");
const lastPathArrowStrRef = useRef("");
useEffect(() => {
const map = mapRef.current;
if (map) {
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
}
}, [mapRef]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[]
) => {
const map = mapRef.current;
if (!map) return;
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource || !polygonLabelSource) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (map.getSource(sourceId)) {
map.removeFeatureState({ source: sourceId });
}
}
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, null, isPreviewModeRef.current)
: renderFc;
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
const countriesStr = JSON.stringify(labeledGeometries);
if (countriesStr !== lastCountriesStrRef.current) {
countriesSource.setData(labeledGeometries);
lastCountriesStrRef.current = countriesStr;
}
const placesStr = JSON.stringify(labeledPoints);
if (placesStr !== lastPlacesStrRef.current) {
placesSource.setData(labeledPoints);
lastPlacesStrRef.current = placesStr;
}
const polygonLabelsStr = JSON.stringify(polygonLabels);
if (polygonLabelsStr !== lastPolygonLabelStrRef.current) {
polygonLabelSource.setData(polygonLabels);
lastPolygonLabelStrRef.current = polygonLabelsStr;
}
const pathArrowSource = map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (pathArrowSource) {
const pathArrowStr = JSON.stringify(pathArrowShapes);
if (pathArrowStr !== lastPathArrowStrRef.current) {
pathArrowSource.setData(pathArrowShapes);
lastPathArrowStrRef.current = pathArrowStr;
}
}
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
requestAnimationFrame(() => {
if (mapRef.current !== map) return;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft);
}
}, [mapRef]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
// Nếu đã có tọa độ lưu từ phiên làm việc trước, không tự động dịch chuyển nữa
try {
if (window.localStorage.getItem("uhm:mapViewport")) {
geolocationCenteredRef.current = true;
return;
}
} catch {
// ignore
}
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
if (!map) return;
geolocationCenteredRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (mapRef.current !== map) return;
const { longitude, latitude } = pos.coords;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
// Dùng jumpTo để teleport lập tức, loại bỏ hoạt ảnh trượt camera kéo dài
map.jumpTo({ center: [longitude, latitude], zoom: nextZoom });
},
() => { },
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
);
}, [mapRef, geolocationCenteredRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlay);
}, [imageOverlay, mapRef]);
useEffect(() => {
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = renderDraft.features.some((f) => String(f.properties.id) === String(editingId));
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
applyGeometryBindingFilter,
geometryVisibility,
applyRenderDraftToMap,
editingEngineRef,
]);
useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current;
const target = focusFeatureCollectionRef.current;
if (!target || !target.features.length) return;
if (!map) return;
let cancelled = false;
let rafId: number | null = null;
const focus = () => {
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
fitMapToFeatureCollection(map, target, focusPaddingRef.current, {
duration: 550,
maxZoom: 10,
pointZoom: 9,
});
};
if (map.isStyleLoaded()) {
rafId = requestAnimationFrame(focus);
} else {
map.once("idle", focus);
}
return () => {
cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [focusRequestKey, mapRef]);
return {
applyRenderDraftToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlayRef.current);
},
};
}
type MapWithRenderDraftRef = maplibregl.Map & {
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
};
@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "uhm-public-first-guide-seen-v1";
type GuideItem = {
title: string;
details?: string[];
};
const guideItems: GuideItem[] = [
{
title: "Kéo bản đồ để di chuyển, cuộn để phóng to.",
},
{
title: "Dùng timeline phía dưới để chọn năm lịch sử.",
details: [
"Kéo chuột sang trái hoặc phải để điều chỉnh năm hiển thị.",
"Lăn chuột trên timeline để điều chỉnh phạm vi kéo.",
"Nhập trực tiếp năm vào ô số để đi nhanh tới mốc cần xem.",
"Range là phạm vi năm muốn hiển thị. Ví dụ time 1990 và range 5 sẽ hiển thị thông tin từ 1985 đến 1995.",
],
},
{
title: "Nhấn vào vùng, điểm hoặc đường trên bản đồ để mở wiki.",
details: [
"Có thể dùng Shift + lăn chuột để chọn thông tin chi tiết muốn tìm hiểu.",
],
},
{
title: "Bật/tắt lớp bản đồ ở bảng bên trái.",
details: [
"Có thể bật/tắt các đối tượng bản đồ tự nhiên lẫn lịch sử.",
],
},
{
title: "Nếu đối tượng có replay, nhấn nút phát để xem diễn biến.",
details: [
"Trong quá trình replay có thể dừng và tương tác như bình thường(những vì vấn đề kĩ thuật chúng tôi chưa thể làm việc đó mà không xảy ra lỗi, nên hiện tại chức năng replay bị hạn chế tương tác).",
],
},
{
title: "Sử dụng wiki để tìm kiếm thông tin liên quan.",
details: [
"Đối với các link trong wiki, màu xanh là đã có thông tin, màu đỏ là chưa có thông tin.",
"Chọn link bằng chuột trái để hiển thị wiki đích và bản đồ tự di chuyển đến khu vực liên quan.",
"Có thể nhấn chuột phải vào các link để có thêm lựa chọn khác.",
],
},
];
export default function FirstVisitGuideModal() {
const [isOpen, setIsOpen] = useState(() => shouldShowFirstVisitGuide());
const closeGuide = useCallback(() => {
try {
window.localStorage.setItem(STORAGE_KEY, "1");
} catch {
// Ignore storage failures; closing the modal should still work.
}
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeGuide();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeGuide, isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="first-visit-guide-title"
className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
>
<div className="flex max-h-[calc(100svh-3rem)] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-white/10 bg-white text-slate-950 shadow-2xl">
<div className="border-b border-slate-200 px-5 py-4 sm:px-6">
<p className="text-sm font-semibold uppercase tracking-wide text-blue-700">
Hướng dẫn nhanh
</p>
<h2 id="first-visit-guide-title" className="mt-1 text-2xl font-bold">
Chào mừng đến Ultimate History Map
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Một vài thao tác chính đ bạn bắt đu xem bản đ lịch sử, wiki replay.
</p>
</div>
<div className="overflow-y-auto px-5 py-4 sm:px-6">
<ol className="space-y-3">
{guideItems.map((item, index) => (
<li key={item.title} className="rounded-md border border-slate-200 bg-slate-50">
{item.details?.length ? (
<details className="group">
<summary className="flex cursor-pointer list-none items-start gap-3 px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-sm font-semibold leading-6 text-slate-900">
{item.title}
</span>
<span className="pt-0.5 text-xl leading-none text-blue-700 group-open:hidden">
+
</span>
<span className="hidden pt-0.5 text-xl leading-none text-blue-700 group-open:block">
-
</span>
</summary>
<ul className="space-y-2 border-t border-slate-200 px-4 py-3 pl-14">
{item.details.map((detail) => (
<li key={detail} className="list-disc text-sm leading-6 text-slate-700">
{detail}
</li>
))}
</ul>
</details>
) : (
<div className="flex items-start gap-3 px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white">
{index + 1}
</span>
<span className="text-sm font-semibold leading-6 text-slate-900">
{item.title}
</span>
</div>
)}
</li>
))}
</ol>
</div>
<div className="flex flex-col gap-3 border-t border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<Link
href="/faq"
onClick={closeGuide}
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-300 px-4 text-sm font-semibold text-slate-700 transition hover:bg-slate-100"
>
Xem hướng dẫn chi tiết
</Link>
<button
type="button"
onClick={closeGuide}
className="inline-flex h-10 items-center justify-center rounded-md bg-slate-900 px-5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Bắt đu khám phá
</button>
</div>
</div>
</div>
);
}
function shouldShowFirstVisitGuide(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) !== "1";
} catch {
return true;
}
}
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { FeatureCollection } from "@/uhm/types/geo";
export type GeometrySelectionGeometry = {
id: string;
center: [number, number] | null;
adminLabel: string | null;
adminAddress: string | null;
};
export type GeometrySelectionRow = {
entity: Entity;
geometries: GeometrySelectionGeometry[];
featureCollection: FeatureCollection;
};
type Props = {
wikiSlug: string;
rows: GeometrySelectionRow[];
isLoading: boolean;
error?: string | null;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function GeometrySelectionPanel({
wikiSlug,
rows,
isLoading,
error,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
ref={containerRef}
style={{
width: "100%",
maxWidth: "100%",
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",
}}
>
<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",
}}
>
Hình bản đ
</div>
<div
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
Chọn thực thể đ phóng tới
</div>
{wikiSlug ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
/wiki/{wikiSlug}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
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="Đóng bảng chọn hình bản đồ"
>
x
</button>
</div>
</div>
<div className="uhm-pinned-geometry-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
{isLoading ? (
<div style={{ display: "grid", gap: 10 }}>
{[0, 1, 2].map((index) => (
<div
key={index}
style={{
height: index === 0 ? 64 : 82,
borderRadius: 10,
background: "rgba(148, 163, 184, 0.12)",
}}
className="animate-pulse"
/>
))}
</div>
) : error ? (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#f87171" }}>
{error}
</div>
) : rows.length ? (
<div style={{ display: "grid", gap: 10 }}>
{rows.map(({ entity, geometries }, index) => {
const adminText = getGeometryAdminText(geometries);
return (
<div
key={entity.id}
style={{
paddingTop: index > 0 ? 12 : 0,
marginTop: index > 0 ? 4 : 0,
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
}}
>
<button
type="button"
onClick={() => onSelectEntity(entity.id)}
style={{
width: "100%",
padding: "9px 10px 9px 12px",
border: "1px solid transparent",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.34)",
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
textAlign: "left",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
className="hover:border-sky-400/30 hover:bg-sky-500/10"
>
<div
style={{
fontSize: 14,
fontWeight: 800,
lineHeight: "20px",
color: "#f8fafc",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.name || String(entity.id)} {formatEntityYears(entity)}
</div>
{adminText ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflowWrap: "anywhere",
}}
>
{adminText}
</div>
) : entity.description?.trim() ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.description.trim()}
</div>
) : null}
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#94a3b8" }}>
Wiki này chưa thực thể hoặc hình bản đ liên quan.
</div>
)}
</div>
<style jsx>{`
.uhm-pinned-geometry-panel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar {
width: 9px;
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
}
`}</style>
</div>
);
}
function formatEntityYears(entity: Entity): string {
const start = Number.isFinite(entity.time_start) ? String(entity.time_start) : "";
const end = Number.isFinite(entity.time_end) ? String(entity.time_end) : "";
if (!start && !end) return "";
return `(${start || "?"}-${end || "?"})`;
}
function getGeometryAdminText(geometries: GeometrySelectionGeometry[]): string {
const labels = geometries
.map((geometry) => geometry.adminLabel || geometry.adminAddress || "")
.map((label) => label.trim())
.filter((label) => label.length > 0);
return Array.from(new Set(labels)).join(" / ");
}
@@ -0,0 +1,151 @@
"use client";
import React from "react";
interface MapPlaceholderProps {
onEnter?: () => void;
}
export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
return (
<div
onClick={onEnter}
style={{
position: "fixed",
inset: 0,
backgroundColor: "#060a13",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 9999,
padding: "24px",
cursor: "pointer",
}}
>
{/* Background image under overlay */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/map_placeholder.webp"
alt="Nền bản đồ"
fetchPriority="high"
loading="eager"
decoding="sync"
width={1920}
height={1080}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 1,
transform: "scale(1.02)",
filter: "brightness(0.25) contrast(1.15) sepia(0.2)",
transition: "transform 10s ease-out",
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
backgroundSize: "cover",
}}
/>
{/* Glowing background gradient lights - Warm Candle & Antique ambiance */}
<div
style={{
position: "absolute",
inset: 0,
zIndex: 2,
background: "radial-gradient(circle at 50% 50%, rgba(217, 119, 6, 0.05) 0%, rgba(6, 10, 19, 0.6) 60%, #060a13 100%)",
}}
/>
{/* Content Container (Antique, Luxurious serif typography) */}
<div
style={{
position: "relative",
zIndex: 3,
width: "100%",
maxWidth: "640px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
>
{/* Title (Largest, gold/yellow color, antique Georgia font) */}
<h1
style={{
fontFamily: "Georgia, serif",
fontSize: "min(52px, 10vw)",
fontWeight: "normal",
letterSpacing: "0.02em",
color: "#f59e0b",
margin: 0,
lineHeight: 1.1,
textShadow: "0 0 20px rgba(245, 158, 11, 0.25), 0 2px 4px rgba(0, 0, 0, 0.8)",
textTransform: "uppercase",
}}
>
Ultimate History Map
</h1>
{/* Subtitle / Description (Right below title, italic, elegant, muted color) */}
<p
style={{
fontFamily: "Georgia, serif",
fontStyle: "italic",
fontSize: "min(16px, 4.5vw)",
color: "#94a3b8",
lineHeight: "1.7",
margin: 0,
maxWidth: "480px",
textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)",
}}
>
Hành trình khám phá biên giới, quốc gia các sự kiện lịch sử thế giới qua bản đ tương tác theo dòng thời gian.
</p>
</div>
{/* Bottom hint "nhấn vào chỗ bất kì để vào" with a slow breathing/fade pulse animation */}
<div
style={{
position: "absolute",
bottom: "50px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "8px",
pointerEvents: "none",
}}
>
<div
style={{
fontFamily: "Georgia, serif",
fontSize: "12px",
fontWeight: "normal",
letterSpacing: "0.2em",
color: "#d97706",
textTransform: "uppercase",
opacity: 0.8,
animation: "placeholder-pulse 2s ease-in-out infinite",
textShadow: "0 0 8px rgba(217, 119, 6, 0.4)",
}}
>
nhấn vào chỗ bất đ vào
</div>
<style dangerouslySetInnerHTML={{
__html: `
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.98); }
50% { opacity: 0.95; transform: scale(1); }
}
`}} />
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,476 @@
"use client";
import Image from "next/image";
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
import { apiGetCurrentUser } from "@/service/auth";
import ChatbotWidget from "@/uhm/components/ui/ChatbotWidget";
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { BackgroundLayerId, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
type Props = {
renderDraft: FeatureCollection;
labelContextDraft: FeatureCollection;
labelTimelineYear?: number | null;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
timelineYear: number;
onTimelineYearChange: (year: number) => void;
timelineTimeRange?: number;
onTimelineTimeRangeChange?: (range: number) => void;
timelineFilterEnabled?: boolean;
onTimelineFilterEnabledChange?: (enabled: boolean) => void;
isTimelineLoading: boolean;
timelineDisabled?: boolean;
timelineStatusText?: string | null;
timelineStyle?: CSSProperties;
onFeatureClick?: (payload: MapFeaturePayload | null) => void;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
onHoverFeatureChange?: (feature: Feature | null) => void;
activeEntity?: Entity | null;
activeWiki?: Wiki | null;
isWikiLoading?: boolean;
wikiError?: string | null;
onCloseWikiSidebar?: () => void;
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxSidebarDragWidth?: number;
onPlayPreviewReplay?: () => void;
mapHandleRef?: React.RefObject<import("@/uhm/components/Map").MapHandle | null>;
overlay?: ReactNode;
children?: ReactNode;
onLoad?: () => void;
instantLoad?: boolean;
onToggleInstantLoad?: (val: boolean) => void;
isLayerPanelVisible?: boolean;
onLayerPanelVisibleChange?: (visible: boolean) => void;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
showViewportControls?: boolean;
hasAnyBottomPanel?: boolean;
};
export default function PreviewMapShell({
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
onSelectFeatureIds,
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
timelineYear,
onTimelineYearChange,
timelineTimeRange,
onTimelineTimeRangeChange,
timelineFilterEnabled,
onTimelineFilterEnabledChange,
isTimelineLoading,
timelineDisabled = false,
timelineStatusText = null,
timelineStyle,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
onHoverFeatureChange,
activeEntity = null,
activeWiki = null,
isWikiLoading = false,
wikiError = null,
onCloseWikiSidebar,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxSidebarDragWidth,
onPlayPreviewReplay,
mapHandleRef,
overlay,
children,
onLoad,
instantLoad = true,
onToggleInstantLoad,
isLayerPanelVisible: propsLayerPanelVisible,
onLayerPanelVisibleChange,
sidebarHeight,
onSidebarHeightChange,
showViewportControls = true,
hasAnyBottomPanel = false,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [localLayerPanelVisible, setLocalLayerPanelVisible] = useState(true);
const isLayerPanelVisible = propsLayerPanelVisible ?? localLayerPanelVisible;
const setIsLayerPanelVisible = onLayerPanelVisibleChange ?? setLocalLayerPanelVisible;
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
useEffect(() => {
const fetchUserAvatar = async () => {
try {
const userData = await apiGetCurrentUser();
const nextAvatarUrl = getCurrentUserAvatarUrl(userData);
if (nextAvatarUrl) setAvatarUrl(nextAvatarUrl);
} catch {
// Guest preview does not need an authenticated profile.
}
};
fetchUserAvatar();
}, []);
const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError);
const hasBottomPanel = hasWikiSidebar || hasAnyBottomPanel;
const menuOptionStyle: CSSProperties = {
width: 46,
height: 46,
backgroundColor: "#1e293b",
color: "#cbd5e1",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.12)",
backdropFilter: "blur(6px)",
};
return (
<div className="relative overflow-hidden bg-gray-950 text-gray-100" style={{ minHeight: "100svh", height: "100svh" }}>
<div className="relative" style={{ minHeight: "100svh", height: "100svh" }}>
<Map
ref={mapHandleRef}
mode="preview"
renderDraft={renderDraft}
labelContextDraft={labelContextDraft}
labelTimelineYear={labelTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={onSelectFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
allowFeatureSelection
applyGeometryBindingFilter
isPreviewMode
onFeatureClick={onFeatureClick}
hoverPopupEnabled={hoverPopupEnabled && !isMobileOrTablet}
getHoverPopupContent={getHoverPopupContent}
onHoverFeatureChange={onHoverFeatureChange}
onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad}
showViewportControls={showViewportControls && !isMobileOrTablet}
height="100svh"
/>
<TimelineBar
year={timelineYear}
onYearChange={onTimelineYearChange}
timeRange={timelineTimeRange}
onTimeRangeChange={onTimelineTimeRangeChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={onTimelineFilterEnabledChange}
style={timelineStyle}
onPlayReplay={onPlayPreviewReplay}
/>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}} />
<aside
style={{
position: "absolute",
top: 10,
bottom: (hasBottomPanel && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
left: 18,
zIndex: 18,
display: "flex",
flexDirection: "column",
gap: 12,
width: 58,
pointerEvents: "none",
transition: "bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
title={isMenuOpen ? "Đóng cài đặt" : "Tham gia hệ thống / Trợ giúp"}
aria-label="Cài đặt"
style={{
width: 46,
height: 46,
backgroundColor: "#1e293b",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
overflow: "hidden",
padding: 0,
}}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt="Cài đặt"
width={46}
height={46}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#cbd5e1"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: "color 0.2s ease" }}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
</button>
{isMenuOpen && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
animation: "slideDown 0.2s ease-out",
}}
>
<button
type="button"
onClick={() => {
if (isMobileOrTablet) {
alert("Tính năng quản trị và chỉnh sửa chỉ hỗ trợ trên máy tính.");
} else {
window.location.href = "/user";
}
}}
title={isMobileOrTablet ? "Tính năng này chỉ hoạt động trên máy tính" : "Quản trị và chỉnh sửa"}
style={{
...menuOptionStyle,
opacity: isMobileOrTablet ? 0.5 : 1,
cursor: isMobileOrTablet ? "not-allowed" : "pointer",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
type="button"
onClick={() => onToggleInstantLoad?.(!instantLoad)}
title="Bật lên để load nhanh hơn"
style={{
...menuOptionStyle,
color: instantLoad ? "#fbbf24" : "#cbd5e1",
border: instantLoad ? "1px solid rgba(251, 191, 36, 0.4)" : "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: instantLoad ? "0 0 12px rgba(251, 191, 36, 0.25)" : "0 2px 8px rgba(0, 0, 0, 0.12)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={instantLoad ? "#fbbf24" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
</button>
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent("toggle-chatbot"));
}}
title="Trợ lý AI Lịch sử (Chatbot)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/faq"; }}
title="Hỏi đáp và hướng dẫn"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/about-us"; }}
title="Về chúng tôi"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
{!isLayerPanelVisible && (
<button
type="button"
onClick={() => setIsLayerPanelVisible(true)}
title="Hiện bảng lớp bản đồ"
style={{
...menuOptionStyle,
color: "#10b981",
border: "1px solid rgba(16, 185, 129, 0.3)",
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.15)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
</div>
)}
</div>
{isLayerPanelVisible && (
<div
style={{
flexGrow: 1,
flexShrink: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={onToggleBackground}
onToggleGeometry={onToggleGeometry}
onHide={() => setIsLayerPanelVisible(false)}
/>
</div>
)}
</aside>
{overlay}
{hasWikiSidebar ? (
<aside
className={isMobileOrTablet ? "uhm-public-wiki-sidebar" : "uhm-public-wiki-sidebar absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
style={isMobileOrTablet ? {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
top: "auto",
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
zIndex: 20,
// Do not transition height during drag resizing for butter smoothness
} : {
width: `min(${sidebarWidth || 420}px, calc(100vw - 2rem))`,
}}
>
<PublicWikiSidebar
entity={activeEntity}
wiki={activeWiki}
isLoading={isWikiLoading}
error={wikiError}
onClose={onCloseWikiSidebar || (() => {})}
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
onWikiLinkEntitySelectionRequest={onWikiLinkEntitySelectionRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth}
compactHeader
sidebarHeight={sidebarHeight}
onSidebarHeightChange={onSidebarHeightChange}
/>
</aside>
) : null}
<ChatbotWidget hideFloatingButton />
{children}
</div>
</div>
);
}
function getCurrentUserAvatarUrl(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const data = (value as { data?: unknown }).data;
if (!data || typeof data !== "object") return null;
const profile = (data as { profile?: unknown }).profile;
if (!profile || typeof profile !== "object") return null;
const avatarUrl = (profile as { avatar_url?: unknown }).avatar_url;
return typeof avatarUrl === "string" && avatarUrl.trim() ? avatarUrl : null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"use client";
export default function PublicPreviewWrapper() {
return null;
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
type Props = {
slug: string;
entities: Entity[];
top: number;
left: number;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function RelatedEntityPopup({
slug,
entities,
top,
left,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && containerRef.current?.contains(target)) {
return;
}
onClose();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [onClose]);
return (
<div
ref={containerRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top, left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
onSelectEntity(entity.id);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type WikiSelectionRow = {
entity: Entity;
wiki: Wiki;
quote: string;
};
type Props = {
rows: WikiSelectionRow[];
onClose: () => void;
onSelectRow: (entityId: string, wikiId: string) => void;
};
export default function WikiSelectionPanel({
rows,
onClose,
onSelectRow,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
ref={containerRef}
style={{
width: "100%",
maxWidth: "100%",
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",
}}
>
<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
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
Chọn wiki đ mở
</div>
</div>
<button
type="button"
onClick={onClose}
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="Đóng bảng chọn wiki"
>
x
</button>
</div>
</div>
<div className="uhm-pinned-wiki-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
<div style={{ display: "grid", gap: 10 }}>
{rows.map(({ entity, wiki, quote }, index) => {
const previous = rows[index - 1];
const startsEntityGroup = !previous || previous.entity.id !== entity.id;
return (
<div key={`${entity.id}:${wiki.id}`}>
{startsEntityGroup ? (
<div
style={{
paddingTop: index > 0 ? 12 : 0,
marginTop: index > 0 ? 4 : 0,
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
}}
>
<div style={{ fontSize: 14, fontWeight: 800, color: "#f8fafc", lineHeight: "20px" }}>
{entity.name || String(entity.id)}
</div>
{entity.description?.trim() ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.description.trim()}
</div>
) : null}
</div>
) : null}
<button
type="button"
onClick={() => onSelectRow(entity.id, wiki.id)}
style={{
width: "100%",
marginTop: 6,
padding: "9px 10px 9px 12px",
border: "1px solid transparent",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.34)",
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
textAlign: "left",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
className="hover:border-sky-400/30 hover:bg-sky-500/10"
>
<div
style={{
fontSize: 13,
fontWeight: 800,
lineHeight: "19px",
color: "#e2e8f0",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
{wiki.title?.trim() || entity.name || String(wiki.id)}
</div>
{quote ? (
<div
style={{
marginTop: 6,
paddingLeft: 10,
borderLeft: "2px solid rgba(56, 189, 248, 0.48)",
fontSize: 12.5,
fontStyle: "italic",
lineHeight: "18px",
color: "#cbd5e1",
display: "-webkit-box",
WebkitLineClamp: 4,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "normal",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
>
{quote}
</div>
) : null}
</button>
</div>
);
})}
</div>
</div>
<style jsx>{`
.uhm-pinned-wiki-panel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar {
width: 9px;
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
}
`}</style>
</div>
);
}
@@ -0,0 +1,461 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchGeometriesByBBox, fetchGeometriesByBoundWith } from "@/uhm/api/geometries";
import { ApiError } from "@/uhm/api/http";
import {
fetchEntitiesByGeometryIds,
fetchWikisByEntityIdsWithPreviews,
} from "@/uhm/api/relations";
import type { Wiki } from "@/uhm/api/wikis";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import {
buildEntityLabelContextDraft,
buildPublicPreviewRelationIndex,
} from "@/uhm/lib/preview/relationIndex";
import {
EMPTY_PREVIEW_RELATIONS,
type PreviewRelationIndex,
} from "@/uhm/lib/preview/types";
import type { Entity } from "@/uhm/types/entities";
import type { FeatureCollection, FeatureEntityPreview, FeatureWikiPreview } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import { fetchBattleReplaysByGeometryIds } from "@/uhm/api/battleReplays";
export function usePublicPreviewData(options: {
timelineYear: number;
timeRange: number;
enabled?: boolean;
}) {
const { timelineYear, timeRange, enabled = true } = options;
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [relations, setRelations] = useState<PreviewRelationIndex>(EMPTY_PREVIEW_RELATIONS);
const [replays, setReplays] = useState<BattleReplay[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
const timelineFetchRequestRef = useRef(0);
const loadedChildGeometryParentIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!enabled) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setIsTimelineLoading(false);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
loadedChildGeometryParentIdsRef.current.clear();
return;
}
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
loadedChildGeometryParentIdsRef.current.clear();
let next: FeatureCollection;
try {
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange, hasBound: false });
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometries failed", err.body);
} else {
console.error("Load public map geometries failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setTimelineStatus("Không tải được dữ liệu bản đồ tại mốc thời gian đã chọn.");
}
return;
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const geometryIds = next.features
.map((feature) => String(feature.properties.id))
.filter((id) => Boolean(id) && UUID_REGEX.test(id));
if (!geometryIds.length) {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(next);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
}
return;
}
setIsRelationsLoading(true);
setRelationsStatus("Đang nạp liên kết entity/wiki.");
let entitiesByGeometryId: Record<string, Entity[]>;
let fetchedReplays: BattleReplay[] = [];
const geometryIdsWithReplays = getGeometryIdsWithReplays(next);
try {
const [entities, replaysRes] = await Promise.all([
fetchEntitiesByGeometryIds(geometryIds),
geometryIdsWithReplays.length
? fetchBattleReplaysByGeometryIds(geometryIdsWithReplays).catch((err) => {
console.error("Failed to load replays:", err);
return {};
})
: Promise.resolve({}),
]);
entitiesByGeometryId = entities;
const uniqueReplaysMap = new Map<string, BattleReplay>();
for (const list of Object.values(replaysRes)) {
for (const item of list || []) {
if (item && item.id) {
uniqueReplaysMap.set(item.id, item);
}
}
}
fetchedReplays = Array.from(uniqueReplaysMap.values());
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometry-entity relations failed", err.body);
} else {
console.error("Load public map geometry-entity relations failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(next); // Fallback to new geometry even if relations failed
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
setIsRelationsLoading(false);
}
return;
}
const entityIds = uniqueStrings(
Object.values(entitiesByGeometryId)
.flat()
.map((entity) => entity.id)
);
let wikisByEntityId: Record<string, Wiki[]> = {};
if (entityIds.length) {
try {
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load initial entity-wiki previews failed", err.body);
} else {
console.error("Load initial entity-wiki previews failed", err);
}
}
}
const entityOnlyRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
);
// Apply BOTH geometries and relations at the exact same React render cycle!
setData(next);
setRelations(entityOnlyRelations);
setReplays(fetchedReplays);
// Mark loading as complete immediately so map transitions and becomes interactive
setIsRelationsLoading(false);
setRelationsStatus(null);
}
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear, timeRange, enabled]);
const labelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations),
[data, relations]
);
const ensureChildrenForGeometry = useCallback(async (parentGeometryId: string | number | null | undefined) => {
const parentId = String(parentGeometryId || "").trim();
if (!parentId || loadedChildGeometryParentIdsRef.current.has(parentId)) return;
loadedChildGeometryParentIdsRef.current.add(parentId);
let childFc: FeatureCollection;
try {
childFc = await fetchGeometriesByBoundWith(parentId);
} catch (err) {
loadedChildGeometryParentIdsRef.current.delete(parentId);
console.error("Load child geometries failed", err);
return;
}
const childGeometryIds = uniqueStrings(childFc.features.map((feature) => String(feature.properties.id || "")));
if (!childGeometryIds.length) return;
const childGeometryIdsWithReplays = getGeometryIdsWithReplays(childFc);
setData((prev) => mergeFeatureCollections(prev, childFc));
let entitiesByGeometryId: Record<string, Entity[]> = {};
let wikisByEntityId: Record<string, Wiki[]> = {};
try {
entitiesByGeometryId = await fetchEntitiesByGeometryIds(childGeometryIds);
const entityIds = uniqueStrings(
Object.values(entitiesByGeometryId)
.flat()
.map((entity) => entity.id)
);
if (entityIds.length) {
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
}
} catch (err) {
console.error("Load child geometry relations failed", err);
}
const childRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(childFc, entitiesByGeometryId, wikisByEntityId)
);
setRelations((prev) => mergePreviewRelationIndexes(prev, childRelations));
try {
if (!childGeometryIdsWithReplays.length) return;
const replayRows = await fetchBattleReplaysByGeometryIds(childGeometryIdsWithReplays);
const childReplays = Object.values(replayRows).flat();
if (childReplays.length) {
setReplays((prev) => mergeReplays(prev, childReplays));
}
} catch (err) {
console.error("Load child geometry replays failed", err);
}
}, []);
return {
data,
renderDraft: labelContextDraft,
labelContextDraft,
relations,
setRelations,
isTimelineLoading,
timelineStatus,
isRelationsLoading,
relationsStatus,
replays,
ensureChildrenForGeometry,
};
}
function buildRelationInputFromGeometryRelations(
draft: FeatureCollection,
entitiesByGeometryId: Record<string, Entity[]>,
wikisByEntityId: Record<string, Wiki[]>
): {
entities: Entity[];
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
} {
const entitiesById: Record<string, Entity> = {};
const entityGeometriesById: Record<string, FeatureCollection> = {};
const mergedWikisByEntityId: Record<string, Wiki[]> = {};
for (const feature of draft.features) {
const geometryId = String(feature.properties.id);
const embeddedEntities = Array.isArray(feature.properties.public_entity_previews)
? feature.properties.public_entity_previews
: [];
for (const entity of entitiesByGeometryId[geometryId] || []) {
const id = String(entity?.id || "").trim();
if (!id) continue;
entitiesById[id] = entity;
pushFeature(entityGeometriesById, id, feature);
}
for (const entityPreview of embeddedEntities) {
const entity = featureEntityPreviewToEntity(entityPreview);
if (!entity) continue;
entitiesById[entity.id] = {
...entity,
...entitiesById[entity.id],
};
pushFeature(entityGeometriesById, entity.id, feature);
pushWikis(
mergedWikisByEntityId,
entity.id,
(entityPreview.wikis || []).map(featureWikiPreviewToWiki).filter((wiki): wiki is Wiki => Boolean(wiki))
);
}
}
for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) {
pushWikis(mergedWikisByEntityId, entityId, wikis || []);
}
return {
entities: Object.values(entitiesById),
entityGeometriesById,
entityWikisById: mergedWikisByEntityId,
};
}
function featureEntityPreviewToEntity(preview: FeatureEntityPreview): Entity | null {
const id = String(preview?.id || "").trim();
if (!id) return null;
return {
id,
name: String(preview.name || id).trim() || id,
description: preview.description ?? null,
time_start: preview.time_start ?? null,
time_end: preview.time_end ?? null,
};
}
function featureWikiPreviewToWiki(preview: FeatureWikiPreview): Wiki | null {
const id = String(preview?.id || "").trim();
if (!id) return null;
return {
id,
project_id: "",
title: preview.title || undefined,
slug: preview.slug ?? null,
content: preview.content || "",
preview_quote: preview.preview_quote ?? null,
};
}
function pushWikis(target: Record<string, Wiki[]>, entityId: string, wikis: Wiki[]) {
const id = String(entityId || "").trim();
if (!id) return;
if (!target[id]) target[id] = [];
for (const wiki of wikis || []) {
if (!wiki?.id) continue;
const existingIndex = target[id].findIndex((item) => item.id === wiki.id);
if (existingIndex >= 0) {
target[id][existingIndex] = {
...target[id][existingIndex],
...wiki,
};
} else {
target[id].push(wiki);
}
}
}
function pushFeature(target: Record<string, FeatureCollection>, entityId: string, feature: FeatureCollection["features"][number]) {
if (!target[entityId]) target[entityId] = { type: "FeatureCollection", features: [] };
if (!target[entityId].features.some((item) => String(item.properties.id) === String(feature.properties.id))) {
target[entityId].features.push(feature);
}
}
function mergeFeatureCollections(base: FeatureCollection, incoming: FeatureCollection): FeatureCollection {
const byId = new Map<string, FeatureCollection["features"][number]>();
for (const feature of base.features || []) {
byId.set(String(feature.properties.id), feature);
}
for (const feature of incoming.features || []) {
byId.set(String(feature.properties.id), feature);
}
return {
type: "FeatureCollection",
features: Array.from(byId.values()),
};
}
function mergePreviewRelationIndexes(base: PreviewRelationIndex, incoming: PreviewRelationIndex): PreviewRelationIndex {
return {
entitiesById: {
...base.entitiesById,
...incoming.entitiesById,
},
entityGeometriesById: mergeFeatureCollectionRecords(base.entityGeometriesById, incoming.entityGeometriesById),
entityWikisById: mergeWikiRecords(base.entityWikisById, incoming.entityWikisById),
geometryEntityIds: mergeStringArrayRecords(base.geometryEntityIds, incoming.geometryEntityIds),
wikiEntityIdsById: mergeStringArrayRecords(base.wikiEntityIdsById, incoming.wikiEntityIdsById),
wikiEntityIdsBySlug: mergeStringArrayRecords(base.wikiEntityIdsBySlug, incoming.wikiEntityIdsBySlug),
wikiById: {
...base.wikiById,
...incoming.wikiById,
},
wikiBySlug: {
...base.wikiBySlug,
...incoming.wikiBySlug,
},
};
}
function mergeFeatureCollectionRecords(
base: Record<string, FeatureCollection>,
incoming: Record<string, FeatureCollection>
): Record<string, FeatureCollection> {
const next = { ...base };
for (const [entityId, fc] of Object.entries(incoming || {})) {
next[entityId] = next[entityId] ? mergeFeatureCollections(next[entityId], fc) : fc;
}
return next;
}
function mergeWikiRecords(base: Record<string, Wiki[]>, incoming: Record<string, Wiki[]>): Record<string, Wiki[]> {
const next: Record<string, Wiki[]> = { ...base };
for (const [entityId, wikis] of Object.entries(incoming || {})) {
next[entityId] = mergeWikis(next[entityId] || [], wikis || []);
}
return next;
}
function mergeWikis(base: Wiki[], incoming: Wiki[]): Wiki[] {
const byId = new Map<string, Wiki>();
for (const wiki of base || []) {
if (wiki?.id) byId.set(wiki.id, wiki);
}
for (const wiki of incoming || []) {
if (wiki?.id) {
byId.set(wiki.id, {
...byId.get(wiki.id),
...wiki,
});
}
}
return Array.from(byId.values());
}
function mergeStringArrayRecords(base: Record<string, string[]>, incoming: Record<string, string[]>): Record<string, string[]> {
const next: Record<string, string[]> = { ...base };
for (const [key, values] of Object.entries(incoming || {})) {
next[key] = uniqueStrings([...(next[key] || []), ...(values || [])]);
}
return next;
}
function mergeReplays(base: BattleReplay[], incoming: BattleReplay[]): BattleReplay[] {
const byId = new Map<string, BattleReplay>();
for (const replay of base || []) {
if (replay?.id) byId.set(String(replay.id), replay);
}
for (const replay of incoming || []) {
if (replay?.id) byId.set(String(replay.id), replay);
}
return Array.from(byId.values());
}
function getGeometryIdsWithReplays(fc: FeatureCollection): string[] {
return uniqueStrings((fc.features || [])
.filter((feature) => Array.isArray(feature.properties.replay_ids) && feature.properties.replay_ids.length > 0)
.map((feature) => String(feature.properties.id || "")));
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => value.length > 0)
));
}
@@ -0,0 +1,911 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Entity } from "@/uhm/api/entities";
import { fetchWikisByEntityIdsWithPreviews } from "@/uhm/api/relations";
import {
fetchWikiBySlug,
getContentByVersionWikiId,
type Wiki,
} from "@/uhm/api/wikis";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
import { isTimelineYearWithinEntityTimeRange } from "@/uhm/lib/utils/entityTime";
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
type CachedWiki = Wiki & { __fetched?: boolean };
type HoverWikiPreview = {
rows: Array<{
wiki: Wiki;
quote: string;
}>;
isLoaded: boolean;
};
export type LinkEntityPopupState = {
slug: string;
entities: Entity[];
top: number;
left: number;
};
export function usePublicPreviewInteraction(options: {
data: FeatureCollection;
relations: PreviewRelationIndex;
setRelations: React.Dispatch<React.SetStateAction<PreviewRelationIndex>>;
selectedFeatureIds: (string | number)[];
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
timelineYear?: number | null;
replayActiveWikiId?: string | null;
replayMode?: "idle" | "playing" | "paused";
onWikiLinkNavigate?: (wiki: Wiki) => void | Promise<void>;
}) {
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode, onWikiLinkNavigate } = options;
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
useEffect(() => {
setIsManualSidebarOpen(false);
}, [replayMode]);
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [visibleWiki, setVisibleWiki] = useState<CachedWiki | null>(null);
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
const wikiLinkRequestSeqRef = useRef(0);
const wikiLinkInFlightSlugRef = useRef<string | null>(null);
const fullWikiFetchAttemptedSlugRef = useRef<Set<string>>(new Set());
const suppressSelectedFeatureAutoSelectRef = useRef(false);
useEffect(() => {
if (replayMode !== "idle" && replayActiveWikiId) {
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
const entityId = activeWikiEntityIds[0] || null;
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || null;
if (entityId) {
setActiveEntityId(entityId);
}
if (wikiSlug) {
setActiveWikiSlug(wikiSlug);
}
}
}, [replayMode, replayActiveWikiId, relations.wikiEntityIdsById, relations.wikiById]);
useEffect(() => {
if (!selectedFeatureIds.length) return;
const stillExistIds = selectedFeatureIds.filter((id) =>
data.features.some((feature) => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [data.features, selectedFeatureIds, setSelectedFeatureIds]);
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
const activeWiki = useMemo(() => {
if (visibleWiki) return visibleWiki;
if (!activeWikiSlug) return null;
const cachedWiki = findWikiBySlug(wikiCache, activeWikiSlug) || null;
const relationWiki = findWikiBySlug(relations.wikiBySlug, activeWikiSlug) || null;
if (!cachedWiki) return relationWiki;
if (hasWikiContent(cachedWiki) || cachedWiki.id === "__not_found__") return cachedWiki;
if (relationWiki && hasWikiContent(relationWiki)) return relationWiki;
return cachedWiki;
}, [activeWikiSlug, relations.wikiBySlug, visibleWiki, wikiCache]);
const selectEntity = useCallback(async (
entityId: string,
selectOptions?: {
sourceFeatureId?: string | number | null;
preferredWikiSlug?: string | null;
selectGeometry?: boolean;
}
) => {
let entity = relations.entitiesById[entityId] || null;
let linkedWikis = relations.entityWikisById[entityId] || [];
if (!entity) {
try {
const { fetchEntityById } = await import("@/uhm/api/entities");
entity = await fetchEntityById(entityId);
const { fetchWikisByEntityIdsWithPreviews } = await import("@/uhm/api/relations");
const wikisRes = await fetchWikisByEntityIdsWithPreviews([entityId]);
linkedWikis = wikisRes[entityId] || [];
setRelations((prev) => {
const wikiById = { ...prev.wikiById };
const wikiBySlug = { ...prev.wikiBySlug };
const wikiEntityIdsById = { ...prev.wikiEntityIdsById };
const wikiEntityIdsBySlug = { ...prev.wikiEntityIdsBySlug };
for (const w of linkedWikis) {
wikiById[w.id] = w;
if (w.slug) {
wikiBySlug[w.slug] = w;
if (!wikiEntityIdsBySlug[w.slug]) wikiEntityIdsBySlug[w.slug] = [];
if (!wikiEntityIdsBySlug[w.slug].includes(entityId)) {
wikiEntityIdsBySlug[w.slug].push(entityId);
}
}
if (!wikiEntityIdsById[w.id]) wikiEntityIdsById[w.id] = [];
if (!wikiEntityIdsById[w.id].includes(entityId)) {
wikiEntityIdsById[w.id].push(entityId);
}
}
return {
...prev,
entitiesById: {
...prev.entitiesById,
[entityId]: entity,
},
entityWikisById: {
...prev.entityWikisById,
[entityId]: linkedWikis,
},
wikiById,
wikiBySlug,
wikiEntityIdsById,
wikiEntityIdsBySlug,
};
});
} catch (err) {
console.error("Failed to lazy load entity/wikis:", err);
return;
}
}
const preferredWikiSlug = String(selectOptions?.preferredWikiSlug || "").trim();
if (!linkedWikis.length || (preferredWikiSlug && !linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug))) {
try {
const fetchedWikis = await fetchRelationWikisForEntity(entityId);
if (fetchedWikis.length) {
linkedWikis = fetchedWikis;
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, fetchedWikis));
setWikiCache((prev) => ({
...wikisBySlug(fetchedWikis),
...prev,
}));
}
} catch (err) {
console.error("Failed to load entity wikis before selecting:", err);
}
}
const nextWikiSlug =
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
? preferredWikiSlug
: "") ||
firstWikiSlug(linkedWikis);
const cachedFullWiki = nextWikiSlug ? findWikiWithContentBySlug(wikiCache, nextWikiSlug) || null : null;
setActiveEntityId(entityId);
setActiveWikiSlug(nextWikiSlug);
setVisibleWiki(cachedFullWiki);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds, wikiCache]);
const selectWiki = useCallback(async (
wiki: Wiki
) => {
const entityIds = relations.wikiEntityIdsById[wiki.id] || [];
if (entityIds.length > 0) {
await selectEntity(entityIds[0], {
preferredWikiSlug: wiki.slug,
});
return;
}
if (wiki.slug) {
const slug = wiki.slug;
const cachedFullWiki = findWikiWithContentBySlug(wikiCache, slug) || null;
setWikiCache((prev) => ({
...prev,
[slug]: cachedFullWiki || {
...wiki,
__fetched: false,
},
}));
setActiveWikiSlug(slug);
setVisibleWiki(cachedFullWiki);
}
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [relations.wikiEntityIdsById, selectEntity, wikiCache]);
useEffect(() => {
if (!selectedFeatureIds.length) {
suppressSelectedFeatureAutoSelectRef.current = false;
return;
}
if (suppressSelectedFeatureAutoSelectRef.current) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return;
const linkedWikis = relations.entityWikisById[onlyEntityId] || [];
if (linkedWikis.length !== 1) return;
selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureIds[0],
preferredWikiSlug: linkedWikis[0]?.slug,
selectGeometry: false,
});
}, [activeEntityId, relations.entityWikisById, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const loadHoverWikiPreviewForEntity = useCallback(async (entityId: string) => {
try {
const relationWikis = relations.entityWikisById[entityId] || [];
const wikis = relationWikis.length ? relationWikis : await fetchRelationWikisForEntity(entityId);
if (!relationWikis.length && wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
}
const rows = wikis.map((wiki) => ({
wiki,
quote: cleanPreviewQuoteText(wiki.preview_quote),
}));
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: {
rows,
isLoaded: true,
},
}));
} catch (err) {
console.error("Load hover wiki preview failed", err);
hoverWikiPreviewRequestsRef.current.delete(entityId);
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: { rows: [], isLoaded: true },
}));
}
}, [relations.entityWikisById, setRelations]);
const getHoverPopupContent = useCallback((feature: Feature): MapHoverPopupContent | null => {
const featureId = feature.properties.id;
const entityIds = relations.geometryEntityIds[String(featureId)] || [];
const entities = entityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (!entities.length) return null;
type GroupedHoverRow = MapHoverPopupContent["rows"][number] & { isTimelineMatch: boolean };
const groupedRows: GroupedHoverRow[] = entities.flatMap((entity): GroupedHoverRow[] => {
const isTimelineMatch = isTimelineYearWithinEntityTimeRange(timelineYear, entity.time_start, entity.time_end);
const preview = hoverWikiPreviewByEntityId[entity.id] ||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
if (!preview && !hoverWikiPreviewRequestsRef.current.has(entity.id)) {
hoverWikiPreviewRequestsRef.current.add(entity.id);
void loadHoverWikiPreviewForEntity(entity.id);
}
const baseClick = (preferredWikiSlug: string | null = null) => {
selectEntity(entity.id, {
sourceFeatureId: featureId,
preferredWikiSlug,
selectGeometry: true,
});
};
const entityHeaderRow = {
title: entity.name,
description: entity.description,
isGroupHeader: true,
isTimelineMatch,
};
if (preview?.rows.length) {
return [
entityHeaderRow,
...preview.rows.map((row) => ({
title: getWikiHoverTitle(row.wiki, entity.name),
isTimelineMatch,
quote: row.quote,
onClick: () => baseClick(String(row.wiki.slug || "").trim() || null),
})),
];
}
if (preview?.isLoaded) {
return [entityHeaderRow, {
title: "(chưa có wiki)",
titleTone: "danger",
isTimelineMatch,
quoteTone: "danger",
}];
}
return [entityHeaderRow, {
title: "Đang tải wiki...",
isTimelineMatch,
quote: "Đang tải trích dẫn wiki...",
onClick: () => baseClick(null),
}];
});
const timelineMatchedRows = groupedRows.filter((row) => row.isTimelineMatch);
const otherRows = groupedRows.filter((row) => !row.isTimelineMatch);
const stripGroupFlag = ({ isTimelineMatch: _isTimelineMatch, ...row }: GroupedHoverRow) => row;
return {
key: entities
.map((entity) => {
const preview = hoverWikiPreviewByEntityId[entity.id] ||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
return `${entity.id}:${preview?.isLoaded ? "loaded" : "loading"}:${preview?.rows.map((row) => row.quote).join("/") || ""}`;
})
.join("|") + `:${timelineYear ?? "none"}`,
rows: [
...timelineMatchedRows.map(stripGroupFlag),
...otherRows.map((row, index) => ({
...stripGroupFlag(row),
separatorBefore: index === 0 && timelineMatchedRows.length > 0,
})),
],
};
}, [
hoverWikiPreviewByEntityId,
loadHoverWikiPreviewForEntity,
relations.entitiesById,
relations.entityWikisById,
relations.geometryEntityIds,
selectEntity,
timelineYear,
]);
useEffect(() => {
if (!linkEntityPopup) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setLinkEntityPopup(null);
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && linkEntityPopupRef.current?.contains(target)) return;
setLinkEntityPopup(null);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [linkEntityPopup]);
useEffect(() => {
if (!activeEntityId || activeWikiSlug) return;
const existingWikis = relations.entityWikisById[activeEntityId] || [];
const existingSlug = firstWikiSlug(existingWikis);
if (existingSlug) {
setActiveWikiSlug(existingSlug);
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const wikis = await fetchRelationWikisForEntity(activeEntityId);
if (disposed) return;
if (wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, activeEntityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
const nextSlug = firstWikiSlug(wikis);
if (nextSlug) {
setActiveWikiSlug(nextSlug);
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (!disposed) {
console.error("Load entity wikis failed", err);
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki cho entity đã chọn.");
}
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
const cachedWiki = activeWikiSlug ? findWikiBySlug(wikiCache, activeWikiSlug) : undefined;
const fetchFullWikiBySlug = useCallback(async (slug: string): Promise<Wiki | null> => {
const row = await fetchWikiBySlug(slug);
if (!row) return null;
let versionContent = row.content;
try {
if (row.content_sample?.[0]?.id) {
const res = await getContentByVersionWikiId(row.content_sample[0].id);
const content = extractWikiContentFromResponse(res);
if (content) versionContent = content;
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
return { ...row, content: versionContent };
}, []);
const focusWikiLinkAfterPaint = useCallback((wiki: Wiki) => {
if (!onWikiLinkNavigate) return;
const run = () => {
void Promise.resolve(onWikiLinkNavigate(wiki)).catch((err) => {
console.error("Failed to focus map for wiki link:", err);
});
};
if (typeof window === "undefined") {
run();
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(run);
});
}, [onWikiLinkNavigate]);
const publishWikiToPanel = useCallback((wiki: Wiki, requestedSlug?: string | null): CachedWiki => {
const canonicalSlug = String(wiki.slug || "").trim() || String(requestedSlug || "").trim();
const fullWiki: CachedWiki = {
...wiki,
slug: canonicalSlug,
__fetched: true,
};
if (requestedSlug) fullWikiFetchAttemptedSlugRef.current.add(requestedSlug);
if (canonicalSlug) fullWikiFetchAttemptedSlugRef.current.add(canonicalSlug);
setWikiCache((prev) => cacheWikiBySlug(prev, fullWiki, requestedSlug));
setActiveWikiSlug(canonicalSlug);
setVisibleWiki(fullWiki);
setActiveWikiError(hasWikiContent(fullWiki) ? null : "Wiki này chưa có nội dung.");
setIsActiveWikiLoading(false);
return fullWiki;
}, []);
const prepareManualWikiNavigation = useCallback(() => {
suppressSelectedFeatureAutoSelectRef.current = true;
setSelectedFeatureIds([]);
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [setSelectedFeatureIds]);
useEffect(() => {
if (!activeWikiSlug) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
setVisibleWiki(null);
return;
}
if (wikiLinkInFlightSlugRef.current === activeWikiSlug) {
return;
}
if (cachedWiki?.id === "__not_found__") {
setIsActiveWikiLoading(false);
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
return;
}
if (cachedWiki && cachedWiki.__fetched && hasWikiContent(cachedWiki)) {
setVisibleWiki(cachedWiki);
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
if (cachedWiki?.__fetched && fullWikiFetchAttemptedSlugRef.current.has(activeWikiSlug)) {
setIsActiveWikiLoading(false);
setActiveWikiError(hasWikiContent(cachedWiki) ? null : "Wiki này chưa có nội dung.");
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const row = await fetchFullWikiBySlug(activeWikiSlug);
if (disposed) return;
if (row) {
publishWikiToPanel(row, activeWikiSlug);
} else {
fullWikiFetchAttemptedSlugRef.current.add(activeWikiSlug);
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
}));
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (disposed) return;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeWikiSlug, cachedWiki, fetchFullWikiBySlug, publishWikiToPanel]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const nextSlug = String(slug || "").trim();
if (!nextSlug) return;
const requestSeq = ++wikiLinkRequestSeqRef.current;
wikiLinkInFlightSlugRef.current = nextSlug;
prepareManualWikiNavigation();
const cachedWikiForSlug =
findWikiWithContentBySlug(wikiCache, nextSlug) ||
findWikiWithContentBySlug(relations.wikiBySlug, nextSlug) ||
null;
if (cachedWikiForSlug && cachedWikiForSlug.id !== "__not_found__" && hasWikiContent(cachedWikiForSlug)) {
wikiLinkInFlightSlugRef.current = null;
const fullWiki = publishWikiToPanel(cachedWikiForSlug, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
return;
}
setIsActiveWikiLoading(true);
let row: Wiki | null = null;
try {
row = await fetchFullWikiBySlug(nextSlug);
} catch (err) {
console.error("Load wiki by slug failed", err);
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
setIsActiveWikiLoading(false);
return;
}
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
if (!row) {
setActiveWikiError("Không tìm thấy wiki.");
setIsActiveWikiLoading(false);
return;
}
const fullWiki = publishWikiToPanel(row, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
}, [
fetchFullWikiBySlug,
focusWikiLinkAfterPaint,
prepareManualWikiNavigation,
publishWikiToPanel,
relations.wikiBySlug,
wikiCache,
]);
const closeWikiSidebar = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setSelectedFeatureIds([]);
setIsManualSidebarOpen(false);
}, [setSelectedFeatureIds]);
const closeWikiSidebarPreserveSelection = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(false);
}, []);
return {
activeEntity,
activeWiki,
isActiveWikiLoading,
activeWikiError,
linkEntityPopup,
linkEntityPopupRef,
getHoverPopupContent,
selectEntity,
selectWiki,
handleWikiLinkRequest,
closeWikiSidebar,
closeWikiSidebarPreserveSelection,
setLinkEntityPopup,
isManualSidebarOpen,
setIsManualSidebarOpen,
};
}
async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
const rows = await fetchWikisByEntityIdsWithPreviews([entityId]);
return rows[entityId] || [];
}
function extractWikiContentFromResponse(response: unknown): string {
if (typeof response === "string") return response;
if (!response || typeof response !== "object") return "";
const source = response as Record<string, unknown>;
if (typeof source.content === "string") return source.content;
const data = source.data;
if (data && typeof data === "object" && typeof (data as Record<string, unknown>).content === "string") {
return (data as Record<string, unknown>).content as string;
}
return "";
}
function hasWikiContent(wiki: Wiki | null | undefined): boolean {
return typeof wiki?.content === "string" && wiki.content.trim().length > 0;
}
function cacheWikiBySlug(
prev: Record<string, CachedWiki>,
wiki: CachedWiki,
requestedSlug?: string | null
): Record<string, CachedWiki> {
const next = { ...prev };
for (const key of wikiSlugCacheKeys(requestedSlug, wiki.slug)) {
next[key] = wiki;
}
return next;
}
function findWikiWithContentBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const direct = findWikiBySlug(source, slug);
if (direct && hasWikiContent(direct)) return direct;
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
if (!hasWikiContent(wiki)) continue;
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function findWikiBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const keys = wikiSlugCacheKeys(slug);
for (const key of keys) {
const direct = source[key];
if (direct) return direct;
}
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function wikiSlugCacheKeys(...values: Array<string | null | undefined>): string[] {
const keys = new Set<string>();
for (const value of values) {
const raw = String(value || "").trim();
if (!raw) continue;
keys.add(raw);
let decoded = raw;
try {
decoded = decodeURIComponent(raw);
} catch {
decoded = raw;
}
decoded = decoded.replace(/^\/+/, "").replace(/^wiki\//i, "").trim();
if (!decoded) continue;
keys.add(decoded);
keys.add(decoded.replace(/_/g, " "));
keys.add(decoded.replace(/\s+/g, "_"));
keys.add(normalizeWikiSlugForCompare(decoded));
}
return Array.from(keys).filter((key) => key.length > 0);
}
function normalizeWikiSlugForCompare(value: string | null | undefined): string {
let raw = String(value || "").trim();
if (!raw) return "";
try {
raw = decodeURIComponent(raw);
} catch {
// Keep the original value if it is not valid percent-encoded text.
}
return raw
.replace(/^\/+/, "")
.replace(/^wiki\//i, "")
.replace(/_/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLocaleLowerCase("vi-VN");
}
function cleanPreviewQuoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
let rawText = blockquoteMatch ? (blockquoteMatch[1]?.trim() || "") : content.trim();
rawText = rawText.replace(/<\/?blockquote[^>]*>/gi, "");
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function buildPresetHoverPreview(wikis: Wiki[]): HoverWikiPreview | undefined {
const rows = (wikis || [])
.map((wiki) => ({
wiki,
quote: cleanPreviewQuoteText(wiki.preview_quote),
}));
return rows.length ? { rows, isLoaded: true } : undefined;
}
function extractWikiBlockquoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
const rawText = blockquoteMatch?.[1]?.trim() || "";
if (!rawText) return "";
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function mergeEntityWikisIntoRelations(
prev: PreviewRelationIndex,
entityId: string,
wikis: Wiki[]
): PreviewRelationIndex {
const wikiById = { ...prev.wikiById };
const wikiBySlug = { ...prev.wikiBySlug };
const entityWikis = [...(prev.entityWikisById[entityId] || [])];
const wikiEntityIdsById = cloneStringArrayRecord(prev.wikiEntityIdsById);
const wikiEntityIdsBySlug = cloneStringArrayRecord(prev.wikiEntityIdsBySlug);
for (const wiki of wikis) {
if (!wiki?.id) continue;
wikiById[wiki.id] = wiki;
if (!entityWikis.some((item) => item.id === wiki.id)) entityWikis.push(wiki);
appendUnique(wikiEntityIdsById, wiki.id, entityId);
const slug = String(wiki.slug || "").trim();
if (slug) {
wikiBySlug[slug] = wiki;
appendUnique(wikiEntityIdsBySlug, slug, entityId);
}
}
return {
...prev,
entityWikisById: {
...prev.entityWikisById,
[entityId]: entityWikis,
},
wikiEntityIdsById,
wikiEntityIdsBySlug,
wikiById,
wikiBySlug,
};
}
function wikisBySlug(wikis: Wiki[]): Record<string, Wiki> {
const result: Record<string, Wiki> = {};
for (const wiki of wikis) {
const slug = String(wiki?.slug || "").trim();
if (slug) result[slug] = wiki;
}
return result;
}
function firstWikiSlug(wikis: Wiki[]): string | null {
return wikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) || null;
}
function getWikiHoverTitle(wiki: Wiki | null | undefined, fallbackTitle: string): string {
return String(wiki?.title || "").trim() || fallbackTitle;
}
function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [key, value] of Object.entries(source)) {
result[key] = [...value];
}
return result;
}
function appendUnique(target: Record<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) target[key].push(value);
}
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
const margin = 12;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, viewportWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, viewportHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
return { top, left };
}
+49
View File
@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
size?: "sm" | "md";
variant?: "primary" | "outline";
startIcon?: ReactNode;
endIcon?: ReactNode;
};
export default function Button({
children,
size = "md",
variant = "primary",
startIcon,
endIcon,
className = "",
disabled = false,
type = "button",
...rest
}: ButtonProps) {
const sizeClasses = {
sm: "px-4 py-3 text-sm",
md: "px-5 py-3.5 text-sm",
};
const variantClasses = {
primary:
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
outline:
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
};
return (
<button
className={`inline-flex items-center justify-center font-medium gap-2 rounded-lg transition ${className} ${
sizeClasses[size]
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={disabled}
type={type}
{...rest}
>
{startIcon && <span className="flex items-center">{startIcon}</span>}
{children}
{endIcon && <span className="flex items-center">{endIcon}</span>}
</button>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
type ChatbotWidgetProps = {
projectId?: string;
hideFloatingButton?: boolean;
};
export default function ChatbotWidget({
projectId = "",
hideFloatingButton = false,
}: ChatbotWidgetProps) {
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import type { LabelHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
children: ReactNode;
};
export default function Label({ children, className, ...props }: LabelProps) {
return (
<label
className={twMerge(
"mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400",
className
)}
{...props}
>
{children}
</label>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
className?: string;
children: ReactNode;
showCloseButton?: boolean;
isFullscreen?: boolean;
};
export function Modal({
isOpen,
onClose,
children,
className = "",
showCloseButton = true,
isFullscreen = false,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const contentClasses = isFullscreen
? "w-full h-full"
: "relative w-full rounded-xl bg-white dark:bg-gray-900";
return (
<div className="fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto modal">
{!isFullscreen && (
<button
type="button"
aria-label="Close modal backdrop"
className="fixed inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
/>
)}
<div
ref={modalRef}
className={`${contentClasses} ${className}`}
onClick={(event) => event.stopPropagation()}
>
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
)}
<div>{children}</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import type { PointerEvent as ReactPointerEvent } from "react";
type ResizeHandleProps = {
onDrag: (deltaX: number) => void;
title: string;
};
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
if (deltaX !== 0) {
onDrag(deltaX);
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
onPointerDown={handlePointerDown}
style={{
width: 6,
cursor: "col-resize",
background: "rgba(148, 163, 184, 0.08)",
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
flex: "0 0 auto",
}}
/>
);
}
+856
View File
@@ -0,0 +1,856 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
import styles from "@/styles/TimelineBar.module.css";
type Props = {
year: number;
onYearChange: (year: number) => void;
timeRange?: number;
onTimeRangeChange?: (range: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
filterEnabled?: boolean;
onFilterEnabledChange?: (enabled: boolean) => void;
style?: React.CSSProperties;
onPlayReplay?: () => void;
};
export default function TimelineBar({
year,
onYearChange,
timeRange,
onTimeRangeChange,
isLoading,
disabled,
statusText,
filterEnabled,
onFilterEnabledChange,
style,
onPlayReplay,
}: Props) {
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYearValue(year, lower, upper);
const [localYear, setLocalYear] = useState<number | null>(null);
const displayYear = localYear ?? safeYear;
const localYearRef = useRef(displayYear);
const onYearChangeRef = useRef(onYearChange);
useEffect(() => {
localYearRef.current = displayYear;
}, [displayYear]);
useEffect(() => {
onYearChangeRef.current = onYearChange;
}, [onYearChange]);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastTriggeredYearRef = useRef<number | null>(null);
const lastTriggerTimeRef = useRef<number>(0);
const commitYearChange = useCallback((nextVal: number) => {
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
if (!Number.isFinite(clamped)) return;
lastTriggeredYearRef.current = clamped;
lastTriggerTimeRef.current = Date.now();
onYearChangeRef.current(clamped);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
}, [lower, upper]);
const handleLocalYearChange = useCallback((nextVal: number) => {
if (!Number.isFinite(nextVal)) {
return;
}
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
localYearRef.current = clamped;
setLocalYear(clamped);
const now = Date.now();
if (now - lastTriggerTimeRef.current >= 100) {
commitYearChange(clamped);
} else {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
commitYearChange(clamped);
}, 100);
}
}, [lower, upper, commitYearChange]);
const finishLocalYearChange = useCallback(() => {
commitYearChange(localYearRef.current);
setLocalYear(null);
}, [commitYearChange]);
useEffect(() => {
if (localYear !== null) return;
localYearRef.current = safeYear;
lastTriggeredYearRef.current = safeYear;
}, [localYear, safeYear]);
const startChangingYear = (direction: number) => {
if (effectiveDisabled) return;
const nextVal = localYearRef.current + direction;
handleLocalYearChange(nextVal);
timeoutRef.current = setTimeout(() => {
intervalRef.current = setInterval(() => {
const currentVal = localYearRef.current;
const targetVal = currentVal + direction;
if (targetVal < lower || targetVal > upper) {
stopChangingYear();
return;
}
handleLocalYearChange(targetVal);
}, 80);
}, 400);
};
const stopChangingYear = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
finishLocalYearChange();
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, []);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || null;
const handleTimeRangeChange = (nextValue: number) => {
if (!onTimeRangeChange) return;
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
onTimeRangeChange(Math.max(0, Math.min(30, safe)));
};
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
if (isMobile && typeof timeRange === "number" && timeRange !== 0 && onTimeRangeChange) {
onTimeRangeChange(0);
}
}, [isMobile, timeRange, onTimeRangeChange]);
if (isMobile) {
return (
<div
style={{
position: "absolute",
zIndex: style?.zIndex ?? 10,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "none",
...style,
left: style?.left ?? 18,
right: style?.right ?? 18,
bottom: style?.bottom ?? 16,
}}
title={helperText || undefined}
>
{/* Year Controls row above */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
margin: "0 auto",
background: "transparent",
border: "none",
borderRadius: "0px",
padding: "6px 0",
pointerEvents: "auto",
boxShadow: "none",
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<>
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Đang bật lọc timeline" : "Đang tắt lọc timeline (hiển thị tất cả geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
className={`${styles.toggleTrack} ${filterEnabled ? styles.toggleTrackActive : ""}`}
>
<span
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
</button>
<div style={{ width: 1, height: 16, backgroundColor: "rgba(255, 255, 255, 0.15)" }} />
</>
) : null}
{/* Adjust decrease button on the left of input */}
<button
type="button"
onMouseDown={() => startChangingYear(-1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(-1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Giảm 1 năm"
aria-label="Giảm 1 năm"
>
-
</button>
{/* Year Input */}
<input
type="number"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onBlur={finishLocalYearChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
finishLocalYearChange();
}
}}
disabled={effectiveDisabled}
className={styles.numberInput}
style={{ width: 90, textAlign: "center", height: 32, padding: "0 6px" }}
aria-label="Timeline exact year"
/>
{/* Adjust increase button on the right of input */}
<button
type="button"
onMouseDown={() => startChangingYear(1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Tăng 1 năm"
aria-label="Tăng 1 năm"
>
+
</button>
{onPlayReplay ? (
<>
<div style={{ width: 1, height: 16, backgroundColor: "rgba(255, 255, 255, 0.15)" }} />
<button
type="button"
onClick={onPlayReplay}
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: "#2563eb",
border: "1px solid rgba(255, 255, 255, 0.2)",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
flexShrink: 0,
}}
title="Xem diễn biến lịch sử (Replay)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
</>
) : null}
</div>
{/* Ruler row below: full width */}
<div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
style={{
position: "static", // Override absolute positioning of .container
padding: "8px 12px",
pointerEvents: "auto",
width: "100%",
borderRadius: "24px",
}}
>
<div className={styles.flexWrapper} style={{ width: "100%", display: "flex" }}>
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
/>
</div>
</div>
</div>
);
}
return (
<div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
style={style}
title={helperText || undefined}
>
<div className={styles.flexWrapper}>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
className={`${styles.toggleTrack} ${filterEnabled ? styles.toggleTrackActive : ""}`}
>
<span
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
</button>
) : null}
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
/>
<div className={styles.numberWrapper}>
<input
type="number"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onBlur={finishLocalYearChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
finishLocalYearChange();
}
}}
disabled={effectiveDisabled}
className={styles.numberInput}
aria-label="Timeline exact year"
/>
<div className={styles.adjustGroup}>
<button
type="button"
onMouseDown={() => startChangingYear(-1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(-1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
title="Giảm 1 năm"
aria-label="Giảm 1 năm"
>
-
</button>
<button
type="button"
onMouseDown={() => startChangingYear(1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
title="Tăng 1 năm"
aria-label="Tăng 1 năm"
>
+
</button>
</div>
</div>
{typeof timeRange === "number" && onTimeRangeChange ? (
<label
title="time_range (0-30)"
className={`${styles.rangeLabel} ${effectiveDisabled ? styles.disabled : ""}`}
>
<span>Range</span>
<input
type="number"
min={0}
max={30}
step={1}
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
disabled={effectiveDisabled}
className={styles.rangeInput}
aria-label="Timeline range"
/>
</label>
) : null}
</div>
</div>
);
}
function formatYear(year: number): string {
if (year < 0) {
return `${Math.abs(year)} TCN`;
}
return `${year}`;
}
interface CanvasRulerProps {
year: number;
onYearChange: (year: number) => void;
onYearCommit: () => void;
minYear: number;
maxYear: number;
disabled?: boolean;
}
function CanvasTimelineRuler({
year,
onYearChange,
onYearCommit,
minYear,
maxYear,
disabled = false,
}: CanvasRulerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Visible span (in years)
const [span, setSpan] = useState(400); // default show 400 years
// Dimensions
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
// Internal tracker for current display year to decouple render lag
const displayYearRef = useRef(year);
// Dragging state
const dragRef = useRef<{
isDragging: boolean;
startX: number;
startYear: number;
hasDragged: boolean;
} | null>(null);
const activePointersRef = useRef<Record<number, { clientX: number; clientY: number }>>({});
const initialPinchDistanceRef = useRef<number | null>(null);
const initialSpanRef = useRef<number | null>(null);
// Sync dimensions using ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
if (!entries || !entries[0]) return;
const { width, height } = entries[0].contentRect;
setDimensions({ width, height: height || 48 });
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Draw the ruler on canvas
const drawYear = useCallback((currentYear: number) => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = dimensions.width;
const height = dimensions.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
// Center year is the selected year
const startYear = currentYear - span / 2;
const endYear = currentYear + span / 2;
const yearToX = (y: number) => {
return ((y - startYear) / span) * width;
};
// Determine tick step based on span
let majorStep = 100;
let mediumStep = 10;
let minorStep = 1;
if (span > 3000) {
majorStep = 1000;
mediumStep = 100;
minorStep = 10;
} else if (span > 1500) {
majorStep = 500;
mediumStep = 50;
minorStep = 10;
} else if (span > 600) {
majorStep = 100;
mediumStep = 20;
minorStep = 5;
} else if (span > 200) {
majorStep = 100;
mediumStep = 10;
minorStep = 1;
} else if (span > 60) {
majorStep = 50;
mediumStep = 10;
minorStep = 1;
} else {
majorStep = 10;
mediumStep = 5;
minorStep = 1;
}
// Ticks drawing bounds
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
const pixelsPerYear = width / span;
const showMinor = pixelsPerYear * minorStep >= 3;
const showMedium = pixelsPerYear * mediumStep >= 5;
// Draw ruler track baseline
ctx.beginPath();
ctx.moveTo(0, height - 8);
ctx.lineTo(width, height - 8);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
// 1. Draw minor & medium ticks
ctx.beginPath();
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
if (y < minYear || y > maxYear) continue;
const isMajor = y % majorStep === 0;
const isMedium = y % mediumStep === 0;
const isMinor = y % minorStep === 0;
if (isMajor) continue;
let tickHeight = 0;
if (isMedium && showMedium) {
tickHeight = 7;
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
} else if (isMinor && showMinor) {
tickHeight = 4;
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
}
if (tickHeight > 0) {
const x = yearToX(y);
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 8 - tickHeight);
}
}
ctx.lineWidth = 1;
ctx.stroke();
// 2. Draw major ticks and labels
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
if (y < minYear || y > maxYear) continue;
const x = yearToX(y);
// Draw tick line
ctx.beginPath();
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 20);
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
ctx.lineWidth = 1.25;
ctx.stroke();
// Draw label
const label = formatYear(y);
ctx.fillText(label, x, height - 33);
}
// 3. Draw needle indicator in the center
const needleX = width / 2;
ctx.beginPath();
ctx.moveTo(needleX, 0);
ctx.lineTo(needleX, height - 4);
ctx.strokeStyle = "#10b981";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
ctx.shadowBlur = 6;
ctx.stroke();
// Draw needle head triangle
ctx.fillStyle = "#10b981";
ctx.beginPath();
ctx.moveTo(needleX - 5, 0);
ctx.lineTo(needleX + 5, 0);
ctx.lineTo(needleX, 6);
ctx.closePath();
ctx.fill();
ctx.restore();
}, [span, dimensions, minYear, maxYear]);
// Redraw when dimensions change
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = dimensions.width * dpr;
canvas.height = dimensions.height * dpr;
drawYear(displayYearRef.current);
}, [dimensions, drawYear]);
// Redraw when span changes
useEffect(() => {
drawYear(displayYearRef.current);
}, [span, drawYear]);
// Sync externally changed year
useEffect(() => {
if (!dragRef.current || !dragRef.current.isDragging) {
displayYearRef.current = year;
drawYear(year);
}
}, [year, drawYear]);
const handleWheel = (e: React.WheelEvent) => {
if (disabled) return;
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
setSpan(Math.round(nextSpan));
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
const activePointerIds = Object.keys(activePointersRef.current);
if (activePointerIds.length === 2) {
const p1 = activePointersRef.current[Number(activePointerIds[0])];
const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
initialPinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy);
initialSpanRef.current = span;
if (dragRef.current) {
dragRef.current.isDragging = false;
}
} else {
dragRef.current = {
isDragging: true,
startX: e.clientX,
startYear: displayYearRef.current,
hasDragged: false,
};
}
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
const activePointerIds = Object.keys(activePointersRef.current);
if (activePointerIds.length === 2 && initialPinchDistanceRef.current !== null && initialSpanRef.current !== null) {
const p1 = activePointersRef.current[Number(activePointerIds[0])];
const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
const currentDist = Math.sqrt(dx * dx + dy * dy);
if (currentDist > 5) {
const zoomFactor = initialPinchDistanceRef.current / currentDist;
const nextSpan = Math.max(10, Math.min(10000, initialSpanRef.current * zoomFactor));
setSpan(Math.round(nextSpan));
}
} else if (dragRef.current && dragRef.current.isDragging) {
const dx = e.clientX - dragRef.current.startX;
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
const yearsPerPixel = span / dimensions.width;
const deltaYears = -dx * yearsPerPixel;
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
if (nextYear !== displayYearRef.current) {
displayYearRef.current = nextYear;
requestAnimationFrame(() => {
drawYear(displayYearRef.current);
});
onYearChange(nextYear);
}
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault();
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
delete activePointersRef.current[e.pointerId];
if (Object.keys(activePointersRef.current).length < 2) {
initialPinchDistanceRef.current = null;
initialSpanRef.current = null;
}
if (!dragRef.current) return;
const dragInfo = dragRef.current;
dragRef.current = null;
if (dragInfo.isDragging && !dragInfo.hasDragged) {
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const clickedX = e.clientX - rect.left;
const centerYear = displayYearRef.current;
const startYear = centerYear - span / 2;
const clickedYear = clampYearValue(
Math.round(startYear + (clickedX / rect.width) * span),
minYear,
maxYear
);
displayYearRef.current = clickedYear;
drawYear(clickedYear);
onYearChange(clickedYear);
}
}
onYearCommit();
};
return (
<div
ref={containerRef}
style={{
flex: 1,
height: 44,
position: "relative",
background: "rgba(255, 255, 255, 0.04)",
borderRadius: 22,
border: "1px solid rgba(255, 255, 255, 0.08)",
overflow: "hidden",
cursor: disabled ? "not-allowed" : "ew-resize",
touchAction: "none",
}}
onWheel={handleWheel}
>
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
touchAction: "none",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
type Props = {
kind: UnifiedSearchKind;
onKindChange: (kind: UnifiedSearchKind) => void;
query: string;
onQueryChange: (query: string) => void;
disabledGeo?: boolean;
debounceMs?: number;
onLocalQueryChange?: (query: string) => void;
};
export default function UnifiedSearchBar({
kind,
onKindChange,
query,
onQueryChange,
disabledGeo,
debounceMs = 300,
onLocalQueryChange,
}: Props) {
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
const [localQuery, setLocalQuery] = useState(query);
const debounceTimerRef = useRef<number | null>(null);
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
useEffect(() => {
setLocalQuery(query);
}, [query]);
useEffect(() => {
onLocalQueryChange?.(localQuery);
}, [localQuery, onLocalQueryChange]);
// Debounce propagation upwards.
useEffect(() => {
if (localQuery === query) return;
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(() => {
onQueryChange(localQuery);
}, debounceMs);
return () => {
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
};
}, [localQuery, query, onQueryChange, debounceMs]);
const commitNow = () => {
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
if (localQuery !== query) onQueryChange(localQuery);
};
const selectStyle: CSSProperties = {
width: 110,
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
flex: "0 0 auto",
};
const inputStyle: CSSProperties = {
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
minWidth: 0,
};
const helperText =
kind === "entity"
? "Search entity theo name"
: kind === "wiki"
? "Search wiki theo title"
: "Search geo theo entity name";
return (
<div
style={{
padding: 10,
background: "#0b1220",
borderRadius: 8,
border: "1px solid #1f2937",
display: "grid",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>Search</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{helperText}</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<select
value={kind}
onChange={(e) => onKindChange(e.target.value as UnifiedSearchKind)}
style={selectStyle}
aria-label="Search kind"
>
<option value="entity">Entity</option>
<option value="wiki">Wiki</option>
<option value="geo" disabled={Boolean(disabledGeo)}>
Geo
</option>
</select>
<input
value={localQuery}
onChange={(e) => setLocalQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNow();
}}
onBlur={() => commitNow()}
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
style={inputStyle}
aria-label="Search query"
/>
</div>
</div>
);
}
@@ -0,0 +1,925 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, memo } from "react";
import { createPortal } from "react-dom";
// Loaded dynamically inside the component to prevent render-blocking
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
level: number;
text: string;
};
type Props = {
entity: Entity | null;
wiki: Wiki | null;
isLoading: boolean;
error?: string | null;
onClose: () => void;
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
compactHeader?: boolean;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
};
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
let value = String(raw || "").trim();
if (!value.length) return "";
// Replace non-breaking spaces to allow text wrap
value = value.replaceAll("&nbsp;", " ").replaceAll("\u00a0", " ");
if (value[0] === "<") return value;
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isExternalHref(href: string): boolean {
const h = href.trim().toLowerCase();
return (
h.startsWith("http://") ||
h.startsWith("https://") ||
h.startsWith("mailto:") ||
h.startsWith("tel:") ||
h.startsWith("sms:")
);
}
function extractWikiSlugFromHref(href: string): string {
const raw = String(href || "").trim();
if (!raw.length || raw === "__missing__") return "";
if (raw.startsWith("#wiki:")) return raw.slice("#wiki:".length).trim();
if (raw.startsWith("#")) return "";
const isAbsoluteUrl = /^[a-z][a-z\d+.-]*:/i.test(raw);
const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
if (isAbsoluteUrl) {
try {
const url = new URL(raw, baseOrigin);
if (typeof window !== "undefined" && url.origin !== window.location.origin) return "";
const path = url.pathname.replace(/\/+$/, "");
if (!path.startsWith("/wiki/")) return "";
return decodeWikiSlug(path.slice("/wiki/".length));
} catch {
return "";
}
}
const match = raw.match(/^([^?#]+)([?#].*)?$/);
let slug = String(match?.[1] || "").replace(/^\/+/, "").replace(/\/+$/, "").trim();
if (slug.startsWith("wiki/")) {
slug = slug.slice("wiki/".length).trim();
}
return decodeWikiSlug(slug);
}
function decodeWikiSlug(slug: string): string {
try {
return decodeURIComponent(slug).trim();
} catch {
return slug.trim();
}
}
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
const slugPart = extractWikiSlugFromHref(href);
if (slugPart.length) {
a.setAttribute("href", `#wiki:${slugPart}`);
a.setAttribute("data-wiki-slug", slugPart);
a.setAttribute("target", "_self");
continue;
}
if (isExternalHref(href)) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
}
}
const toc: TocItem[] = [];
const seen = new Map<string, number>();
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
for (const h of headings) {
const text = String(h.textContent || "").trim();
if (!text.length) continue;
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
const existingId = String(h.getAttribute("id") || "").trim();
if (existingId) {
toc.push({ id: existingId, level, text });
continue;
}
const base = slugifyHeading(text) || "heading";
const nextCount = (seen.get(base) || 0) + 1;
seen.set(base, nextCount);
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
h.setAttribute("id", id);
toc.push({ id, level, text });
}
return { html: doc.body.innerHTML, toc };
}
function getWikiContentScrollContainer(root: HTMLElement | null): HTMLElement | null {
return root?.closest<HTMLElement>(".uhm-public-wiki-sidebar-content") ?? null;
}
function PublicWikiSidebar({
entity,
wiki,
isLoading,
error,
onClose,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
compactHeader = false,
sidebarHeight,
onSidebarHeightChange,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
const [wikiLinkMenu, setWikiLinkMenu] = useState<{
slug: string;
rect: DOMRect;
top: number;
left: number;
} | null>(null);
useEffect(() => {
import("react-quill-new/dist/quill.snow.css");
}, []);
const [localWidth, setLocalWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
return parsed;
}
}
}
return 420;
});
const width = sidebarWidth ?? localWidth;
const setWidth = onSidebarWidthChange ?? setLocalWidth;
const maxDragWidthLimit = maxDragWidth ?? 800;
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
setWidth(nextWidth);
if (typeof window !== "undefined") {
localStorage.setItem("public-wiki-sidebar-width", String(nextWidth));
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const processedWiki = useMemo(() => {
if (!wiki) return { html: "", toc: [] as TocItem[] };
const html = normalizeWikiContentToHtml(wiki.content ?? "");
try {
return prepareWikiHtml(html);
} catch (err) {
console.error("Failed to process sidebar wiki HTML", err);
return { html, toc: [] as TocItem[] };
}
}, [wiki]);
const renderHtml = processedWiki.html;
const toc = processedWiki.toc;
useLayoutEffect(() => {
const scrollContainer = getWikiContentScrollContainer(contentRootRef.current);
scrollContainer?.scrollTo({ top: 0, behavior: "auto" });
tocContainerRef.current?.scrollTo({ left: 0, behavior: "auto" });
}, [wiki?.id, wiki?.slug, renderHtml, toc]);
useEffect(() => {
const container = tocContainerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [toc]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
event.preventDefault();
onWikiLinkRequest({ slug, rect: sourceLink.getBoundingClientRect() });
};
root.addEventListener("click", handleClick);
return () => root.removeEventListener("click", handleClick);
}, [onWikiLinkRequest, renderHtml]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
event.preventDefault();
setWikiLinkMenu({
slug,
rect: sourceLink.getBoundingClientRect(),
...computeContextMenuPosition(event.clientX, event.clientY, 220, 88),
});
};
root.addEventListener("contextmenu", handleContextMenu, true);
return () => root.removeEventListener("contextmenu", handleContextMenu, true);
}, [renderHtml]);
useEffect(() => {
if (!wikiLinkMenu) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest?.("[data-wiki-link-context-menu='true']")) return;
setWikiLinkMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setWikiLinkMenu(null);
};
const closeMenu = () => setWikiLinkMenu(null);
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [wikiLinkMenu]);
const handleOpenStandaloneWiki = (slug: string) => {
if (typeof window === "undefined") return;
const url = `/wiki/${encodeURIComponent(slug)}`;
const nextWindow = window.open(url, "_blank", "noopener,noreferrer");
if (nextWindow) nextWindow.opener = null;
};
const isExpanded = useMemo(() => {
if (typeof window === "undefined") return false;
const fullHeight = Math.round(window.innerHeight * 0.70);
return (sidebarHeight || 400) >= fullHeight;
}, [sidebarHeight]);
const handleHeightToggle = () => {
if (typeof window === "undefined") return;
const halfHeight = Math.round(window.innerHeight * 0.45);
const fullHeight = Math.round(window.innerHeight * 0.85);
const currentHeight = sidebarHeight || 400;
const nextHeight = Math.abs(currentHeight - halfHeight) < Math.abs(currentHeight - fullHeight)
? fullHeight
: halfHeight;
if (onSidebarHeightChange) {
onSidebarHeightChange(nextHeight);
}
};
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
return (
<div
style={{
width: isMobileOrTablet ? "100%" : `${width}px`,
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: isMobileOrTablet ? "24px 24px 0 0" : 20,
border: isMobileOrTablet ? "1px solid rgba(148, 163, 184, 0.22)" : "1px solid rgba(148, 163, 184, 0.22)",
borderBottom: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderLeft: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderRight: isMobileOrTablet ? "none" : "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",
}}
>
{/* Grab Handle for bottom sheet on mobile */}
{isMobileOrTablet ? (
<div
onClick={handleHeightToggle}
style={{
width: "100%",
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 60,
userSelect: "none",
flexShrink: 0,
gap: 8,
}}
>
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.3)",
}}
/>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255, 255, 255, 0.5)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s ease",
}}
>
<polyline points="18 15 12 9 6 15" />
</svg>
</div>
) : null}
{/* Drag Handle on the left edge */}
<div
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 6,
cursor: "col-resize",
zIndex: 50,
userSelect: "none",
}}
className="group hidden lg:block"
title="Kéo để chỉnh kích thước"
>
{/* Visual drag line overlay */}
<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
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 }}>
{compactHeader ? null : (
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
)}
<div
style={{
marginTop: compactHeader ? 0 : 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
{wiki?.title?.trim() || entity?.name?.trim() || "Wiki"}
</div>
</div>
<button
type="button"
onClick={onClose}
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="Đóng khung wiki"
>
x
</button>
</div>
</div>
{toc.length ? (
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "8px 12px",
}}
>
<div
ref={tocContainerRef}
className="uhm-public-wiki-toc-list"
style={{
display: "flex",
gap: 8,
overflowX: "auto",
paddingBottom: 4,
}}
>
{toc.slice(0, 8).map((item) => {
return (
<a
key={item.id}
href={`#${item.id}`}
onClick={(e) => {
e.preventDefault();
const root = contentRootRef.current;
if (root) {
const targetElement = root.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null;
const scrollContainer = getWikiContentScrollContainer(root);
if (targetElement && scrollContainer) {
const containerTop = scrollContainer.getBoundingClientRect().top;
const targetTop = targetElement.getBoundingClientRect().top;
const scrollOffset = targetTop - containerTop + scrollContainer.scrollTop;
scrollContainer.scrollTo({
top: scrollOffset - 12,
behavior: "smooth"
});
}
}
}}
style={{
flexShrink: 0,
borderRadius: 9999,
padding: "4px 10px",
fontSize: 11,
fontWeight: 650,
textDecoration: "none",
transition: "all 0.2s",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
border: "1px solid rgba(148, 163, 184, 0.1)",
}}
className="hover:bg-slate-700/40 hover:text-slate-200"
>
{item.text}
</a>
);
})}
</div>
</div>
) : null}
<div
className="uhm-public-wiki-sidebar-content"
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
}}
>
{isLoading && !wiki ? (
<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 style={{ padding: 16, fontSize: 14, color: "#f87171" }}>
{error}
</div>
) : wiki ? (
<div style={{ position: "relative", minHeight: "100%" }}>
{isLoading ? (
<div
aria-hidden="true"
style={{
position: "sticky",
top: 0,
left: 0,
right: 0,
height: 2,
zIndex: 2,
overflow: "hidden",
background: "rgba(56, 189, 248, 0.08)",
}}
>
<div
className="uhm-wiki-sidebar-loading-bar"
style={{
height: "100%",
width: "42%",
background: "linear-gradient(90deg, transparent, #38bdf8, transparent)",
}}
/>
</div>
) : null}
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
) : (
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
Entity này chưa wiki liên kết.
</div>
)}
</div>
{wikiLinkMenu && typeof document !== "undefined"
? createPortal(
<div
data-wiki-link-context-menu="true"
style={{
position: "fixed",
top: wikiLinkMenu.top,
left: wikiLinkMenu.left,
zIndex: 100000,
width: 220,
overflow: "hidden",
borderRadius: 10,
border: "1px solid rgba(148, 163, 184, 0.28)",
background: "rgba(15, 23, 42, 0.98)",
boxShadow: "0 18px 42px rgba(2, 6, 23, 0.48)",
color: "#e2e8f0",
padding: 6,
}}
>
<button
type="button"
onClick={() => {
handleOpenStandaloneWiki(wikiLinkMenu.slug);
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở wiki riêng
</button>
<button
type="button"
onClick={() => {
const request = { slug: wikiLinkMenu.slug, rect: wikiLinkMenu.rect };
if (onWikiLinkEntitySelectionRequest) {
onWikiLinkEntitySelectionRequest(request);
} else {
onWikiLinkRequest(request);
}
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở bảng chọn entity
</button>
</div>,
document.body
)
: null}
<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);
}
@keyframes uhm-wiki-sidebar-loading-bar {
from {
transform: translateX(-120%);
}
to {
transform: translateX(260%);
}
}
.uhm-wiki-sidebar-loading-bar {
animation: uhm-wiki-sidebar-loading-bar 1.1s ease-in-out infinite;
}
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 18px 22px;
line-height: 1.6;
font-size: 14.5px;
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;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
margin: 1.15em 0 0.6em;
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,
.uhm-wiki-sidebar-view.ql-editor h5,
.uhm-wiki-sidebar-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
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 {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-sidebar-view.ql-editor blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
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(148, 163, 184, 0.22);
border-radius: 10px;
background: rgba(15, 23, 42, 0.4);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
color: #cbd5e1;
}
.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: #38bdf8 !important;
transition: color 0.15s ease;
}
.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;
pointer-events: none;
}
.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: #f87171 !important;
}
@media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor {
padding: 14px 14px 20px;
font-size: 13.5px;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
font-size: 1.4em;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
font-size: 1.2em;
}
}
`}</style>
</div>
);
}
const wikiLinkMenuButtonStyle = {
display: "block",
width: "100%",
border: 0,
borderRadius: 7,
background: "transparent",
padding: "9px 10px",
color: "inherit",
cursor: "pointer",
fontSize: 13,
fontWeight: 700,
lineHeight: "18px",
textAlign: "left" as const,
};
function computeContextMenuPosition(clientX: number, clientY: number, width: number, height: number) {
const margin = 8;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
return {
left: Math.max(margin, Math.min(clientX, viewportWidth - width - margin)),
top: Math.max(margin, Math.min(clientY, viewportHeight - height - margin)),
};
}
export default memo(PublicWikiSidebar);
File diff suppressed because it is too large Load Diff