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
Build and Release / release (push) Successful in 55s
This commit is contained in:
@@ -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);
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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 có ả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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 có 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 có 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 có 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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ụ";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 và 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 có 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 và 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 kì để 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(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
type ChatbotWidgetProps = {
|
||||
projectId?: string;
|
||||
hideFloatingButton?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatbotWidget({
|
||||
projectId = "",
|
||||
hideFloatingButton = false,
|
||||
}: ChatbotWidgetProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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(" ", " ").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 có 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
Reference in New Issue
Block a user