From 194b3ad3c24ea4dac67dc91b835f814a8dd12587 Mon Sep 17 00:00:00 2001 From: taDuc Date: Wed, 20 May 2026 02:14:56 +0700 Subject: [PATCH] =?UTF-8?q?add=20somenew=20UI=20editor=20feature=20for=20m?= =?UTF-8?q?ore=20eff=C3=AAcncy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/editor/[id]/EditorSearchResults.tsx | 281 ++++++ src/app/editor/[id]/ResizeHandle.tsx | 49 ++ src/app/editor/[id]/editorPageUtils.ts | 141 ++++ src/app/editor/[id]/featureCommands.ts | 8 +- src/app/editor/[id]/page.tsx | 799 +++++++++--------- src/app/editor/page.tsx | 132 ++- src/app/page.tsx | 46 + src/uhm/components/Editor.tsx | 5 + src/uhm/components/Map.tsx | 68 ++ .../editor/GeometryBindingPanel.tsx | 173 +++- .../components/editor/ImageOverlayPanel.tsx | 127 +++ .../editor/ProjectEntityRefsPanel.tsx | 186 ++-- src/uhm/components/map/imageOverlay.ts | 465 ++++++++++ src/uhm/components/map/mapUtils.ts | 120 ++- src/uhm/components/map/useMapInstance.ts | 22 +- src/uhm/components/map/useMapInteraction.ts | 49 +- src/uhm/components/map/useMapSync.ts | 38 +- src/uhm/doc/map_styling.md | 1 + src/uhm/lib/editor/entity/entityBinding.ts | 15 + .../lib/editor/project/useProjectCommands.ts | 2 + src/uhm/lib/editor/session/sessionTypes.ts | 2 + .../editor/session/useEntitySessionState.ts | 2 + src/uhm/lib/editor/snapshot/editorSnapshot.ts | 29 + src/uhm/lib/map/engines/editingEngine.ts | 203 +++-- src/uhm/lib/map/engines/lineEngine.ts | 13 +- src/uhm/lib/map/engines/pathEngine.ts | 12 +- src/uhm/lib/map/engines/pointEngine.ts | 7 +- src/uhm/lib/map/engines/selectingEngine.ts | 31 +- src/uhm/lib/map/engines/snapUtils.ts | 116 ++- src/uhm/lib/map/geo/geoTypeMap.json | 4 +- src/uhm/lib/map/geo/geometryTypeOptions.ts | 1 + src/uhm/lib/map/styles/geotypeLayers.ts | 2 + src/uhm/lib/map/styles/geotypes/faction.ts | 15 + src/uhm/store/editorStore.tsx | 29 +- src/uhm/types/entities.ts | 4 + src/uhm/types/geo.ts | 8 + 36 files changed, 2608 insertions(+), 597 deletions(-) create mode 100644 src/app/editor/[id]/EditorSearchResults.tsx create mode 100644 src/app/editor/[id]/ResizeHandle.tsx create mode 100644 src/app/editor/[id]/editorPageUtils.ts create mode 100644 src/uhm/components/editor/ImageOverlayPanel.tsx create mode 100644 src/uhm/components/map/imageOverlay.ts create mode 100644 src/uhm/lib/map/styles/geotypes/faction.ts diff --git a/src/app/editor/[id]/EditorSearchResults.tsx b/src/app/editor/[id]/EditorSearchResults.tsx new file mode 100644 index 0000000..9aa5c17 --- /dev/null +++ b/src/app/editor/[id]/EditorSearchResults.tsx @@ -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 ( + <> + + + {searchKind === "entity" && hasQuery ? ( + + {entitySearchResults.slice(0, 8).map((entity) => ( + onAddEntityRefToProject(entity)} + /> + ))} + {!isEntitySearchLoading && entitySearchResults.length === 0 ? : null} + + ) : null} + + {searchKind === "wiki" && hasQuery ? ( + + {wikiSearchResults.slice(0, 8).map((wiki) => ( + onAddWikiRefToProject(wiki)} + /> + ))} + {!isWikiSearching && wikiSearchResults.length === 0 ? : null} + + ) : null} + + {searchKind === "geo" && hasQuery ? ( + + {geoSearchResults.slice(0, 6).map((item) => ( + + ))} + {!isGeoSearching && geoSearchResults.length === 0 ? : null} + + ) : null} + + ); +} + +function SearchBox({ + title, + status, + children, +}: { + title: string; + status: string; + children: ReactNode; +}) { + return ( +
+
+
{title}
+
{status}
+
+
{children}
+
+ ); +} + +function ResultRow({ + title, + subtitle, + actionLabel, + actionTitle, + onAction, +}: { + title: string; + subtitle: string; + actionLabel: string; + actionTitle: string; + onAction: () => void; +}) { + return ( +
+
+
+ {title} +
+
+ {subtitle} +
+
+ +
+ ); +} + +function GeoResultGroup({ + item, + onImportGeoFromSearch, +}: { + item: EntityGeometriesSearchItem; + onImportGeoFromSearch: ( + entityItem: EntityGeometriesSearchItem, + geo: EntityGeometrySearchGeo + ) => void; +}) { + const geometries = Array.isArray(item.geometries) ? item.geometries : []; + + return ( +
+
+
+
+ {item.name?.trim() || item.entity_id} +
+
+ {item.entity_id} +
+
+
+ {geometries.length} geos +
+
+ {item.description?.trim() ? ( +
+ {item.description.trim()} +
+ ) : null} + {geometries.length ? ( +
+ {geometries.map((geo) => ( +
+
+
+ #{geo.id} +
+
+ type: {geo.type || "unknown"}{" "} + {geo.time_start != null || geo.time_end != null + ? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}` + : ""} +
+
+ +
+ ))} +
+ ) : ( +
No geometry linked.
+ )} +
+ ); +} + +function EmptyResult() { + return
No results.
; +} + +const actionButtonStyle: CSSProperties = { + border: "none", + background: "#111827", + color: "#93c5fd", + cursor: "pointer", + borderRadius: 6, + padding: "6px 8px", + fontSize: 12, + fontWeight: 700, +}; diff --git a/src/app/editor/[id]/ResizeHandle.tsx b/src/app/editor/[id]/ResizeHandle.tsx new file mode 100644 index 0000000..d409655 --- /dev/null +++ b/src/app/editor/[id]/ResizeHandle.tsx @@ -0,0 +1,49 @@ +"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) => { + event.preventDefault(); + const startX = event.clientX; + let lastX = startX; + + const onMove = (e: PointerEvent) => { + const deltaX = e.clientX - lastX; + if (deltaX !== 0) { + onDrag(deltaX); + lastX = e.clientX; + } + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+ ); +} diff --git a/src/app/editor/[id]/editorPageUtils.ts b/src/app/editor/[id]/editorPageUtils.ts new file mode 100644 index 0000000..dcf6dc7 --- /dev/null +++ b/src/app/editor/[id]/editorPageUtils.ts @@ -0,0 +1,141 @@ +import type { ProjectCommit } from "@/uhm/api/projects"; +import type { EntitySnapshot } from "@/uhm/types/entities"; +import type { Feature, Geometry } from "@/uhm/types/geo"; +import type { BattleReplay } from "@/uhm/types/projects"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; + +// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ. +export function clampNumber(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập. +export function formatCommitTitle(commit: ProjectCommit): string { + return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`; +} + +// Kiểm tra feature có nằm trong năm timeline đang active hay không. +export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean { + const start = feature.properties.time_start; + const end = feature.properties.time_end; + if (typeof start === "number" && Number.isFinite(start) && year < start) return false; + if (typeof end === "number" && Number.isFinite(end) && year > end) return false; + return true; +} + +// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng. +export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) { + const list = Array.isArray(input) ? input : []; + return list + .filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0) + .filter((w) => { + if (w.source === "ref") return true; + if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true; + const title = typeof w.title === "string" ? w.title.trim() : ""; + const doc = typeof w.doc === "string" ? w.doc.trim() : ""; + return title.length > 0 || (w.doc !== null && doc.length > 0); + }) + .map((w) => ({ + id: w.id, + source: w.source, + title: typeof w.title === "string" ? w.title.trim() : "", + slug: typeof w.slug === "string" ? w.slug : null, + doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null, + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source. +export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) { + const list = Array.isArray(input) ? input : []; + return list + .filter((e) => e && (typeof e.id === "string" || typeof e.id === "number")) + .map((e) => ({ + id: String(e.id), + source: e.source, + name: typeof e.name === "string" ? e.name.trim() : "", + description: e.description == null ? null : String(e.description), + time_start: typeof e.time_start === "number" ? e.time_start : null, + time_end: typeof e.time_end === "number" ? e.time_end : null, + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự. +export function normalizeEntityWikiLinksForCompare( + input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined +) { + const list = Array.isArray(input) ? input : []; + return list + .filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string") + .map((l) => ({ + entity_id: l.entity_id, + wiki_id: l.wiki_id, + operation: l.operation === "delete" ? "delete" : "binding", + })) + .sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id)); +} + +// Chuẩn hóa replay để phát hiện thay đổi script/target geometry. +export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) { + const list = Array.isArray(input) ? input : []; + return list + .filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0) + .map((replay) => ({ + id: typeof replay.id === "string" ? replay.id : replay.geometry_id, + geometry_id: replay.geometry_id, + target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare( + replay.target_geometry_ids, + replay.geometry_id + ), + detail: Array.isArray(replay.detail) ? replay.detail : [], + })) + .sort((a, b) => a.geometry_id.localeCompare(b.geometry_id)); +} + +// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list. +function normalizeReplayTargetGeometryIdsForCompare( + input: string[] | null | undefined, + geometryId: string +) { + const orderedIds: string[] = []; + const seen = new Set(); + + const pushId = (rawId: string | number | null | undefined) => { + if (rawId == null) return; + const id = String(rawId).trim(); + if (!id || seen.has(id)) return; + seen.add(id); + orderedIds.push(id); + }; + + pushId(geometryId); + for (const rawId of input || []) pushId(rawId); + return orderedIds; +} + +// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft. +export function normalizeGeoSearchGeometry(value: unknown): Geometry | null { + if (!value || typeof value !== "object") return null; + const geometry = value as Record; + if (typeof geometry.type !== "string") return null; + if (!("coordinates" in geometry)) return null; + return value as Geometry; +} + +// Chuẩn hóa danh sách binding id từ API search GEO. +export function normalizeGeoSearchBindingIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const deduped: string[] = []; + const seen = new Set(); + for (const rawId of value) { + if (typeof rawId !== "string" && typeof rawId !== "number") continue; + const id = String(rawId).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push(id); + } + return deduped; +} diff --git a/src/app/editor/[id]/featureCommands.ts b/src/app/editor/[id]/featureCommands.ts index fbbb81d..5155131 100644 --- a/src/app/editor/[id]/featureCommands.ts +++ b/src/app/editor/[id]/featureCommands.ts @@ -43,6 +43,7 @@ export function useFeatureCommands(options: Options) { setEntityFormStatus, } = options; + // Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures. const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { if (!selectedFeatures || selectedFeatures.length === 0) { const msg = "Hãy chọn ít nhất một geometry trước."; @@ -50,12 +51,6 @@ export function useFeatureCommands(options: Options) { return { ok: false, error: msg }; } - if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) { - const msg = "time_start và time_end là bắt buộc."; - setEntityFormStatus(msg); - return { ok: false, error: msg }; - } - let metadata; try { metadata = buildGeometryMetadataPatch(geometryMetaForm); @@ -90,6 +85,7 @@ export function useFeatureCommands(options: Options) { setIsEntitySubmitting, ]); + // Áp danh sách entity đã chọn vào toàn bộ selectedFeatures. const applyEntitiesToSelectedGeometry = useCallback(async () => { if (!selectedFeatures || selectedFeatures.length === 0) { setEntityFormStatus("Hãy chọn ít nhất một geometry trước."); diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 81bbc1d..8a7ed8e 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react"; import { useParams, useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import Map, { type MapHandle } from "@/uhm/components/Map"; @@ -16,17 +16,15 @@ import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; +import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { ApiError } from "@/uhm/api/http"; import { fetchCurrentUser } from "@/uhm/api/auth"; -import { ProjectCommit } from "@/uhm/api/projects"; import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; -import type { EntitySnapshot } from "@/uhm/types/entities"; import { Feature, FeatureCollection, - Geometry, useEditorState, } from "@/uhm/lib/editor/state/useEditorState"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; @@ -48,17 +46,35 @@ import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; +import { + getViewportImageCoordinates, + moveImageOverlayCoordinatesByPixels, + scaleImageOverlayCoordinatesByFactor, + type MapImageOverlay, +} from "@/uhm/components/map/imageOverlay"; import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; import { useFeatureCommands } from "./featureCommands"; import { deleteSubmission } from "@/uhm/api/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; -import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar"; import { EditorStoreProvider, useEditorStore, useEditorStoreApi, } from "@/uhm/store/editorStore"; +import { EditorSearchResults } from "./EditorSearchResults"; +import { ResizeHandle } from "./ResizeHandle"; +import { + clampNumber, + formatCommitTitle, + isFeatureVisibleAtYear, + normalizeEntitiesForCompare, + normalizeEntityWikiLinksForCompare, + normalizeGeoSearchBindingIds, + normalizeGeoSearchGeometry, + normalizeReplaysForCompare, + normalizeWikisForCompare, +} from "./editorPageUtils"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; @@ -94,12 +110,19 @@ function EditorPageContent() { const router = useRouter(); const editorStoreApi = useEditorStoreApi(); const projectId = String(params.id || ""); + // Ref chặn auto-open lặp lại cùng project khi component re-render. const openedProjectIdRef = useRef(null); + // Ref giữ timeout flash message của form entity để clear đúng timer cũ. const entityFormStatusTimeoutRef = useRef(null); + // Ref giữ timeout flash message của panel geometry binding. const geoBindingStatusTimeoutRef = useRef(null); + // Ref tracking entity tạo local để cleanup khỏi catalog nếu undo/xóa khỏi snapshot. const localCreatedEntityIdsRef = useRef>(new Set()); + // Ref nhớ geometry vừa chọn để không xóa status khi chỉ patch metadata cùng geometry. const lastSelectedFeatureIdRef = useRef(null); + // Ref bridge sang Map imperative API (getMap/getViewState) cho replay preview. const mapHandleRef = useRef(null); + // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. const { mode, internalSetMode, @@ -176,6 +199,7 @@ function EditorPageContent() { hideOutside, setHideOutside, geometryVisibility, + setGeometryVisibility, } = useEditorStore(useShallow((state) => ({ mode: state.mode, internalSetMode: state.setMode, @@ -252,12 +276,14 @@ function EditorPageContent() { hideOutside: state.hideOutside, setHideOutside: state.setHideOutside, geometryVisibility: state.geometryVisibility, + setGeometryVisibility: state.setGeometryVisibility, }))); - // Counter để bỏ qua response cũ khi user gõ search entity liên tục. + // Counter để bỏ qua response cũ khi user gõ search liên tục. const entitySearchRequestRef = useRef(0); const wikiSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0); + // Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất. const snapshotEntitiesRef = useRef(snapshotEntities); const snapshotWikisRef = useRef(snapshotWikis); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); @@ -271,6 +297,7 @@ function EditorPageContent() { snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks; }, [snapshotEntityWikiLinks]); + // Hook quản lý draft/changes/undo cho main editor và replay editor. const editor = useEditorState(initialData, { snapshotUndo: { snapshotEntitiesRef, @@ -283,18 +310,21 @@ function EditorPageContent() { initialReplays: baselineSnapshot?.replays, mode: mode, }); + // Setter bọc undo cho thao tác cập nhật wiki snapshot. const setSnapshotWikisUndoable = useCallback( (next: SetStateAction) => { editor.setSnapshotWikis(next, "Cập nhật wiki"); }, [editor] ); + // Setter bọc undo cho thao tác cập nhật binding entity-wiki. const setSnapshotEntityWikiLinksUndoable = useCallback( (next: SetStateAction) => { editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki"); }, [editor] ); + // Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung. const snapshotEntitiesAsEntities = useMemo(() => { const rows = snapshotEntities || []; return rows @@ -303,15 +333,19 @@ function EditorPageContent() { id: String(e.id || ""), name: String(e.name || "").trim() || String(e.id || ""), description: e.description ?? null, + time_start: e.time_start ?? null, + time_end: e.time_end ?? null, geometry_count: 0, })) .filter((e) => e.id.length > 0 && e.name.length > 0); }, [snapshotEntities]); + // Entity list hợp nhất giữa backend catalog và snapshot local. const entities = useMemo( () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), [entityCatalog, snapshotEntitiesAsEntities] ); + // State vị trí stage/step đang chọn trong replay editor. const [replaySelection, setReplaySelection] = useState<{ stageId: number | null; stepIndex: number | null; @@ -319,23 +353,99 @@ function EditorPageContent() { stageId: null, stepIndex: null, }); + // State snapshot đóng băng của replay preview, tách khỏi draft đang edit. const [previewSession, setPreviewSession] = useState(null); + // State yêu cầu autoplay sau khi chuyển vào preview mode. const [previewAutoplayMode, setPreviewAutoplayMode] = useState<"start" | "selection" | null>(null); + // Cache wiki đã fetch trong preview để không gọi API lặp lại. const [previewWikiCache, setPreviewWikiCache] = useState>({}); + // State lỗi riêng cho wiki preview sidebar. const [previewWikiError, setPreviewWikiError] = useState(null); + // State loading riêng cho wiki preview sidebar. const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false); + // State ảnh overlay local-only để vẽ trace theo ảnh mẫu. + const [imageOverlay, setImageOverlay] = useState(null); + // Bật/tắt điều khiển ảnh overlay bằng phím mũi tên và W/S. + const [imageOverlayKeyboardEnabled, setImageOverlayKeyboardEnabled] = useState(false); + // Ref giữ object URL hiện tại để revoke khi đổi/xóa ảnh, tránh leak bộ nhớ. + const imageOverlayObjectUrlRef = useRef(null); + // Cập nhật stage/step được chọn trong sidebar replay. const handleReplaySelectionChange = useCallback((stageId: number | null, stepIndex: number | null) => { setReplaySelection({ stageId, stepIndex }); }, []); + // Helper đọc MapLibre instance hiện tại cho replay dispatcher. const getCurrentMapInstance = useCallback(() => mapHandleRef.current?.getMap() ?? null, []); + // Helper đọc camera/view hiện tại để lưu vào replay preview. const getCurrentMapViewState = useCallback(() => mapHandleRef.current?.getViewState() ?? null, []); const isReplayEditMode = mode === "replay"; const isReplayPreviewMode = mode === "replay_preview"; + // Ref mirror entity list cho debounce search không phụ thuộc closure cũ. const entitiesRef = useRef(entities); useEffect(() => { entitiesRef.current = entities; }, [entities]); + useEffect(() => { + return () => { + if (imageOverlayObjectUrlRef.current) { + URL.revokeObjectURL(imageOverlayObjectUrlRef.current); + imageOverlayObjectUrlRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!imageOverlayKeyboardEnabled) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isTypingTarget(event.target)) return; + + const key = event.key.toLowerCase(); + const step = event.shiftKey ? 9.6 : 2.8; + let handled = true; + setImageOverlay((prev) => { + if (!prev) return prev; + const map = getCurrentMapInstance(); + if (!map) return prev; + + if (key === "w") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, -step) }; + } + if (key === "s") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, step) }; + } + if (key === "a") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, -step, 0) }; + } + if (key === "d") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, step, 0) }; + } + if (key === "q") { + return { + ...prev, + coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 1.012, prev.aspectRatio), + }; + } + if (key === "e") { + return { + ...prev, + coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 0.988, prev.aspectRatio), + }; + } + + handled = false; + return prev; + }); + + if (handled) { + event.preventDefault(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [getCurrentMapInstance, imageOverlayKeyboardEnabled]); + useEffect(() => { const localCreatedIds = localCreatedEntityIdsRef.current; if (!localCreatedIds.size) return; @@ -357,10 +467,12 @@ function EditorPageContent() { }); }, [snapshotEntities, setEntityCatalog]); + // Clamp năm timeline vào range cố định trước khi đưa vào store. const handleTimelineYearChange = useCallback((nextYear: number) => { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }, [setTimelineDraftYear]); + // Hook điều phối phát replay preview và các side effect lên map/UI. const replayPreview = useReplayPreview({ replay: previewSession?.replay || null, draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, @@ -373,6 +485,7 @@ function EditorPageContent() { onSelectStep: () => {}, }); + // Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay. const replayPreviewDraft = useMemo(() => { const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION; if (!isReplayPreviewMode || replayPreview.hiddenGeometryIds.length === 0) { @@ -396,6 +509,7 @@ function EditorPageContent() { // Timeline filter: only affects persisted snapshot features. // New features created in the current session remain visible regardless of time range. + // Draft cuối cùng đưa vào map sau khi áp filter timeline. const timelineVisibleDraft = useMemo(() => { const activeDraft = isReplayPreviewMode ? replayPreviewDraft @@ -421,6 +535,7 @@ function EditorPageContent() { replayPreviewDraft, ]); + // Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại. const selectedFeatures = useMemo(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; return selectedFeatureIds @@ -428,19 +543,32 @@ function EditorPageContent() { .filter(Boolean) as Feature[]; }, [selectedFeatureIds, editor.draft.features]); + // Multi-edit chỉ hợp lệ khi các geometry được chọn cùng shape type. const isMultiEditValid = useMemo(() => { if (selectedFeatures.length <= 1) return true; const firstShape = selectedFeatures[0].geometry.type; return selectedFeatures.every(f => f.geometry.type === firstShape); }, [selectedFeatures]); + // Feature đại diện cho panel phải; null khi multi-edit không cùng loại. const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null; + const selectedGeometryTime = useMemo(() => { + if (!selectedFeature) return null; + return { + time_start: selectedFeature.properties.time_start ?? null, + time_end: selectedFeature.properties.time_end ?? null, + }; + }, [selectedFeature]); + // Choices cho panel bind geometry, gồm cả marker geometry mới tạo local. const geometryChoices = useMemo(() => { const createdGeometryIds = new Set(); for (const [id, change] of editor.changes.entries()) { if (change.action === "create") createdGeometryIds.add(String(id)); } + const timelineVisibleGeometryIds = new Set( + timelineVisibleDraft.features.map((feature) => String(feature.properties.id)) + ); const rows = (editor.draft.features || []) .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) @@ -451,18 +579,23 @@ function EditorPageContent() { return { id, label, + time_start: f.properties.time_start ?? null, + time_end: f.properties.time_end ?? null, + isTimelineVisible: timelineVisibleGeometryIds.has(id), isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id), }; }); rows.sort((a, b) => a.id.localeCompare(b.id)); return rows; - }, [editor]); + }, [editor, timelineVisibleDraft.features]); + // Binding ids của geometry đại diện đang chọn. const selectedGeometryBindingIds = useMemo(() => { if (!selectedFeature) return []; return normalizeFeatureBindingIds(selectedFeature); }, [selectedFeature]); + // Choices wiki dùng trong replay actions và binding panel. const wikiChoices = useMemo(() => { return (snapshotWikis || []) .filter((wiki) => wiki && wiki.operation !== "delete") @@ -473,6 +606,7 @@ function EditorPageContent() { .filter((wiki) => wiki.id.length > 0); }, [snapshotWikis]); + // Dirty flag cho wiki snapshot so với baseline commit. const wikiDirty = useMemo(() => { const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); const next = normalizeWikisForCompare(snapshotWikis); @@ -483,6 +617,7 @@ function EditorPageContent() { } }, [baselineSnapshot?.wikis, snapshotWikis]); + // Dirty flag cho entity snapshot so với baseline commit. const entitiesDirty = useMemo(() => { const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities); const next = normalizeEntitiesForCompare(snapshotEntities); @@ -493,6 +628,7 @@ function EditorPageContent() { } }, [baselineSnapshot?.entities, snapshotEntities]); + // Dirty flag cho binding entity-wiki so với baseline commit. const entityWikiDirty = useMemo(() => { const prev = normalizeEntityWikiLinksForCompare(baselineSnapshot?.entity_wiki); const next = normalizeEntityWikiLinksForCompare(snapshotEntityWikiLinks); @@ -503,6 +639,7 @@ function EditorPageContent() { } }, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]); + // Dirty flag cho replay scripts so với baseline commit. const replayDirty = useMemo(() => { const prev = normalizeReplaysForCompare(baselineSnapshot?.replays); const next = normalizeReplaysForCompare(editor.effectiveReplays); @@ -513,17 +650,20 @@ function EditorPageContent() { } }, [baselineSnapshot?.replays, editor.effectiveReplays]); + // Tổng số nhóm thay đổi chưa commit, dùng để enable/disable commit UI. const pendingSaveCount = editor.changeCount + (wikiDirty ? 1 : 0) + (entitiesDirty ? 1 : 0) + (entityWikiDirty ? 1 : 0) + (replayDirty ? 1 : 0); + // Stages của replay đang active, fallback [] để sidebar an toàn. const activeReplayStages = useMemo( () => editor.activeReplayDraft?.detail || [], [editor.activeReplayDraft?.detail] ); + // Commands thao tác project/commit/submission dựa trên draft + store hiện tại. const sectionCommands = useProjectCommands({ editor, store: editorStoreApi, @@ -537,6 +677,7 @@ function EditorPageContent() { restoreCommit, } = sectionCommands; + // Thoát preview và quay về replay edit mode. const exitReplayPreview = useCallback(() => { replayPreview.resetPreview(); setPreviewAutoplayMode(null); @@ -544,6 +685,7 @@ function EditorPageContent() { internalSetMode("replay"); }, [internalSetMode, replayPreview.resetPreview]); + // Đóng băng draft/replay hiện tại thành session preview để phát thử. const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { if (!editor.activeReplayDraft) return; @@ -573,6 +715,7 @@ function EditorPageContent() { timelineFilterEnabled, ]); + // State machine chuyển mode editor, xử lý riêng replay/replay_preview để không mất draft. const setMode = useCallback((m: EditorMode, featureId?: string | number) => { if (m === "replay_preview") { return; @@ -685,6 +828,7 @@ function EditorPageContent() { setIsPreviewWikiLoading(false); }, [previewSession]); + // Label ngắn cho overlay preview tại step đang phát. const replayPreviewActiveStepLabel = useMemo(() => { if ( replayPreview.activeCursor.stageId == null || @@ -696,6 +840,7 @@ function EditorPageContent() { }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); const replayPreviewWikiRows = previewSession?.wikis || []; + // Wiki snapshot đang được step preview yêu cầu mở. const replayPreviewActiveWikiSnapshot = useMemo(() => { if (!replayPreview.activeWikiId) return null; return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; @@ -763,6 +908,7 @@ function EditorPageContent() { replayPreviewWikiRows, ]); + // Wiki đầy đủ cho sidebar preview, ưu tiên doc có sẵn trong snapshot rồi mới dùng cache API. const replayPreviewActiveWiki = useMemo(() => { const snapshotWiki = replayPreviewActiveWikiSnapshot; if (!snapshotWiki) return null; @@ -778,6 +924,7 @@ function EditorPageContent() { return previewWikiCache[snapshotWiki.id] || null; }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); + // Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview. const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { const nextSlug = String(slug || "").trim(); if (!nextSlug.length) return; @@ -790,6 +937,7 @@ function EditorPageContent() { replayPreview.openWikiPanelById(match.id); }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); + // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; @@ -822,6 +970,7 @@ function EditorPageContent() { replayFeatureId, ]); + // Load project editor payload, xử lý auth và pending-submission lock. const openProject = useCallback(async () => { if (!projectId) return; try { @@ -861,6 +1010,7 @@ function EditorPageContent() { } }, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); + // Xóa pending submission để backend cho phép mở editor lại. const unlockByDeletingPendingSubmission = useCallback(async () => { if (!blockedPendingSubmissionId) return; const confirmed = window.confirm("Xoa submission PENDING de unlock editor? Hanh dong nay khong the hoan tac."); @@ -1157,6 +1307,7 @@ function EditorPageContent() { setSelectedGeometryEntityIds, ]); + // Hiển thị status form entity trong thời gian ngắn, tự clear timer cũ. const flashEntityFormStatus = useCallback((msg: string | null, timeoutMs = 3000) => { if (entityFormStatusTimeoutRef.current) { window.clearTimeout(entityFormStatusTimeoutRef.current); @@ -1171,6 +1322,7 @@ function EditorPageContent() { } }, [setEntityFormStatus]); + // Hiển thị status binding geometry trong thời gian ngắn, tự clear timer cũ. const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => { if (geoBindingStatusTimeoutRef.current) { window.clearTimeout(geoBindingStatusTimeoutRef.current); @@ -1190,6 +1342,7 @@ function EditorPageContent() { setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); + // Thêm entity backend vào snapshot project dưới dạng reference. const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; @@ -1202,6 +1355,8 @@ function EditorPageContent() { operation: "reference", name: entity.name, description: entity.description ?? null, + time_start: entity.time_start ?? null, + time_end: entity.time_end ?? null, }, ...prev, ]; @@ -1218,7 +1373,8 @@ function EditorPageContent() { }); }, [editor, setEntityCatalog]); - const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null }) => { + // Cập nhật metadata entity trong snapshot project, có undo qua editor state. + const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => { const id = String(entityId || "").trim(); if (!id) return; const nextName = String(payload?.name || "").trim(); @@ -1227,6 +1383,19 @@ function EditorPageContent() { return; } const nextDescription = payload?.description == null ? null : String(payload.description); + let nextTimeStart: number | undefined; + let nextTimeEnd: number | undefined; + try { + nextTimeStart = parseOptionalEntityYearInput(payload.time_start, "time_start"); + nextTimeEnd = parseOptionalEntityYearInput(payload.time_end, "time_end"); + if (nextTimeStart != null && nextTimeEnd != null && nextTimeStart > nextTimeEnd) { + flashEntityFormStatus("time_start phải <= time_end."); + return; + } + } catch (err) { + flashEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); + return; + } editor.setSnapshotEntities((prev) => prev.map((e) => { if (!e || String(e.id) !== id) return e; @@ -1244,11 +1413,14 @@ function EditorPageContent() { operation, name: nextName, description: nextDescription, + time_start: nextTimeStart, + time_end: nextTimeEnd, }; }), `Cap nhat entity #${id}`); flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000); }, [editor, flashEntityFormStatus]); + // Bind/unbind entity vào toàn bộ selected geometry hợp lệ. const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { flashEntityFormStatus("Chưa chọn geometry để bind entity."); @@ -1302,6 +1474,7 @@ function EditorPageContent() { setSelectedGeometryEntityIds, ]); + // Bind/unbind geometry id vào trường binding của selected geometry. const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { flashGeoBindingStatus("Chưa chọn geometry để bind."); @@ -1367,6 +1540,7 @@ function EditorPageContent() { setIsEntitySubmitting, ]); + // Focus/zoom tới geometry từ binding panel; nếu geo có time_start thì kéo year filter về năm đó. const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => { const id = String(geoId || "").trim(); if (!id) return; @@ -1377,11 +1551,9 @@ function EditorPageContent() { return; } - const visibleInCurrentTimeline = timelineVisibleDraft.features.some( - (item) => String(item.properties.id) === id - ); - if (timelineFilterEnabled && !visibleInCurrentTimeline) { - setTimelineFilterEnabled(false); + const geoTimeStart = feature.properties.time_start; + if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) { + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart))); } setSelectedFeatureIds([feature.properties.id]); @@ -1397,11 +1569,20 @@ function EditorPageContent() { flashGeoBindingStatus, setGeometryFocusRequest, setSelectedFeatureIds, - setTimelineFilterEnabled, - timelineFilterEnabled, - timelineVisibleDraft.features, + setTimelineDraftYear, ]); + const handleHideGeometryLocal = useCallback((geoId: string | number) => { + const id = String(geoId || "").trim(); + if (!id) return; + setGeometryVisibility((prev) => ({ + ...prev, + [id]: false, + })); + setSelectedFeatureIds((prev) => prev.filter((item) => String(item) !== id)); + }, [setGeometryVisibility, setSelectedFeatureIds]); + + // Thêm wiki backend vào snapshot project dưới dạng reference. const handleAddWikiRefToProject = useCallback((wiki: Wiki) => { const id = String(wiki.id || "").trim(); if (!id) return; @@ -1422,6 +1603,87 @@ function EditorPageContent() { setRequestedActiveWikiId(id); }, [editor, setRequestedActiveWikiId]); + // Tạo image overlay từ file local, mặc định phủ theo viewport map hiện tại. + const handlePickImageOverlay = useCallback((file: File | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setEntityStatus("File overlay phải là ảnh."); + return; + } + + const map = getCurrentMapInstance(); + if (!map) { + setEntityStatus("Map chưa sẵn sàng để thêm ảnh overlay."); + return; + } + + const nextUrl = URL.createObjectURL(file); + void readImageAspectRatio(nextUrl) + .then((aspectRatio) => { + const previousUrl = imageOverlayObjectUrlRef.current; + imageOverlayObjectUrlRef.current = nextUrl; + setImageOverlay((prev) => ({ + url: nextUrl, + name: file.name || "Trace image", + opacity: prev?.opacity ?? 0.55, + aspectRatio, + coordinates: getViewportImageCoordinates(map, aspectRatio), + })); + if (previousUrl) { + URL.revokeObjectURL(previousUrl); + } + }) + .catch((err) => { + console.error("Read image size failed", err); + URL.revokeObjectURL(nextUrl); + setEntityStatus("Không đọc được kích thước ảnh overlay."); + }); + }, [getCurrentMapInstance, setEntityStatus]); + + // Đọc ảnh trực tiếp từ clipboard và dùng làm overlay trace. + const handlePasteImageOverlay = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.clipboard?.read) { + setEntityStatus("Trình duyệt không hỗ trợ paste ảnh từ clipboard."); + return; + } + + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((type) => type.startsWith("image/")); + if (!imageType) continue; + const blob = await item.getType(imageType); + const extension = imageType.split("/")[1] || "png"; + const file = new File([blob], `clipboard-image.${extension}`, { type: imageType }); + handlePickImageOverlay(file); + return; + } + setEntityStatus("Clipboard không có ảnh để paste."); + } catch (err) { + console.error("Paste image overlay failed", err); + setEntityStatus("Không paste được ảnh. Hãy cấp quyền clipboard hoặc dùng nút Thêm ảnh."); + } + }, [handlePickImageOverlay, setEntityStatus]); + + // Chỉnh opacity của image overlay mà không đổi vị trí/ảnh. + const handleImageOverlayOpacityChange = useCallback((opacity: number) => { + const nextOpacity = Number.isFinite(opacity) + ? Math.max(0, Math.min(1, opacity)) + : 0.55; + setImageOverlay((prev) => prev ? { ...prev, opacity: nextOpacity } : prev); + }, []); + + // Xóa image overlay khỏi map và revoke object URL local. + const handleRemoveImageOverlay = useCallback(() => { + if (imageOverlayObjectUrlRef.current) { + URL.revokeObjectURL(imageOverlayObjectUrlRef.current); + imageOverlayObjectUrlRef.current = null; + } + setImageOverlay(null); + setImageOverlayKeyboardEnabled(false); + }, []); + + // Import geometry từ kết quả search GEO vào draft hiện tại và bind entity liên quan. const handleImportGeoFromSearch = useCallback(( entityItem: EntityGeometriesSearchItem, geo: EntityGeometrySearchGeo @@ -1510,6 +1772,7 @@ function EditorPageContent() { setTimelineFilterEnabled, ]); + // Commands thao tác metadata/entity binding cho feature đang chọn. const featureCommands = useFeatureCommands({ editor, selectedFeatures, @@ -1522,6 +1785,7 @@ function EditorPageContent() { setEntityFormStatus, }); + // Tạo entity inline chỉ trong snapshot local, chưa gọi backend cho tới khi commit. const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); if (!name) { @@ -1530,6 +1794,19 @@ function EditorPageContent() { } const description = entityForm.description.trim() || null; + let timeStart: number | undefined; + let timeEnd: number | undefined; + try { + timeStart = parseOptionalEntityYearInput(entityForm.time_start, "time_start"); + timeEnd = parseOptionalEntityYearInput(entityForm.time_end, "time_end"); + if (timeStart != null && timeEnd != null && timeStart > timeEnd) { + setEntityFormStatus("time_start phải <= time_end."); + return; + } + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); + return; + } const normalizedName = name.toLowerCase(); const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName); if (duplicatedName) { @@ -1542,6 +1819,8 @@ function EditorPageContent() { id: entityId, name, description, + time_start: timeStart ?? null, + time_end: timeEnd ?? null, geometry_count: 0, }; @@ -1557,6 +1836,8 @@ function EditorPageContent() { operation: "create", name, description, + time_start: timeStart, + time_end: timeEnd, }, ...prev, ]; @@ -1576,6 +1857,8 @@ function EditorPageContent() { ...prev, name: "", description: "", + time_start: "", + time_end: "", })); setEntityStatus(null); setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng."); @@ -1584,18 +1867,25 @@ function EditorPageContent() { } }; + // Commit head hiện tại để hiển thị label lịch sử. const headCommit = projectState?.head_commit_id ? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null : null; + // Tạo geometry từ map engine rồi select ngay geometry mới. const handleCreateFeature = (feature: Feature) => { editor.createFeature(feature); setSelectedFeatureIds([feature.properties.id]); }; - const mapLabelContextDraft = isReplayPreviewMode + // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng. + const mapLabelSourceDraft = isReplayPreviewMode ? previewSession?.draft || EMPTY_FEATURE_COLLECTION : editor.draft; + const mapLabelContextDraft = useMemo( + () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities), + [entities, mapLabelSourceDraft] + ); return (
@@ -1713,10 +2003,12 @@ function EditorPageContent() { onSetMode={setMode} draft={timelineVisibleDraft} labelContextDraft={mapLabelContextDraft} + labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null} selectedFeatureIds={selectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} onDeleteFeature={editor.deleteFeature} + onHideFeature={handleHideGeometryLocal} onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} geometryVisibility={effectiveGeometryVisibility} @@ -1725,6 +2017,8 @@ function EditorPageContent() { focusFeatureCollection={geometryFocusRequest?.collection || null} focusRequestKey={geometryFocusRequest?.key ?? null} focusPadding={96} + imageOverlay={imageOverlay} + onImageOverlayChange={setImageOverlay} /> ) : (
@@ -1811,230 +2105,36 @@ function EditorPageContent() { width={rightPanelWidth} topContent={
- { + { setSearchKind(next); setSearchQuery(""); setSearchQueryDraft(""); }} - query={searchQuery} - onQueryChange={setSearchQuery} - onLocalQueryChange={setSearchQueryDraft} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + onLocalSearchQueryChange={setSearchQueryDraft} + searchQueryDraft={searchQueryDraft} + entitySearchResults={entitySearchResults} + isEntitySearchLoading={isEntitySearchLoading} + onAddEntityRefToProject={handleAddEntityRefToProject} + wikiSearchResults={wikiSearchResults} + isWikiSearching={isWikiSearching} + onAddWikiRefToProject={handleAddWikiRefToProject} + geoSearchResults={geoSearchResults} + isGeoSearching={isGeoSearching} + onImportGeoFromSearch={handleImportGeoFromSearch} + /> + - - {searchKind === "entity" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Entity Results
-
- {isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`} -
-
-
- {entitySearchResults.slice(0, 8).map((e) => ( -
-
-
- {e.name} -
-
- {e.id} -
-
- -
- ))} - {!isEntitySearchLoading && entitySearchResults.length === 0 ? ( -
No results.
- ) : null} -
-
- ) : null} - - {searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Wiki Results
-
- {isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`} -
-
-
- {wikiSearchResults.slice(0, 8).map((w) => ( -
-
-
- {(w.title || "").trim() || "Untitled wiki"} -
-
- {w.id} -
-
- -
- ))} - {!isWikiSearching && wikiSearchResults.length === 0 ? ( -
No results.
- ) : null} -
-
- ) : null} - - {searchKind === "geo" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Geo Results
-
- {isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`} -
-
-
- {geoSearchResults.slice(0, 6).map((item) => ( -
-
-
-
- {item.name?.trim() || item.entity_id} -
-
- {item.entity_id} -
-
-
- {Array.isArray(item.geometries) ? item.geometries.length : 0} geos -
-
- {item.description?.trim() ? ( -
- {item.description.trim()} -
- ) : null} - {Array.isArray(item.geometries) && item.geometries.length ? ( -
- {item.geometries.map((geo) => ( -
-
-
- #{geo.id} -
-
- type: {geo.type || "unknown"}{" "} - {geo.time_start != null || geo.time_end != null - ? `| time: ${geo.time_start ?? "?"} → ${geo.time_end ?? "?"}` - : ""} -
-
- -
- ))} -
- ) : ( -
- No geometry linked. -
- )} -
- ))} - {!isGeoSearching && geoSearchResults.length === 0 ? ( -
No results.
- ) : null} -
-
- ) : null} @@ -2096,174 +2197,76 @@ function EditorPageContent() { ); } -function ResizeHandle({ - onDrag, - title, -}: { - onDrag: (deltaX: number) => void; - title: string; -}) { - const handlePointerDown = (event: ReactPointerEvent) => { - // Only horizontal resize - event.preventDefault(); - const startX = event.clientX; - let lastX = startX; - - const onMove = (e: PointerEvent) => { - const dx = e.clientX - lastX; - if (dx !== 0) { - onDrag(dx); - lastX = e.clientX; +function readImageAspectRatio(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!width || !height) { + reject(new Error("Image has invalid dimensions.")); + return; } + resolve(width / height); }; - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - }; - - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - }; - - return ( -
- ); + image.onerror = () => reject(new Error("Image load failed.")); + image.src = url; + }); } -function clampNumber(value: number, min: number, max: number): number { - if (value < min) return min; - if (value > max) return max; - return value; +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tagName = target.tagName.toLowerCase(); + return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable; } -function formatCommitTitle(commit: ProjectCommit): string { - return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`; -} +function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity[]): FeatureCollection { + if (!draft.features.length) return draft; -function isFeatureVisibleAtYear(feature: Feature, year: number): boolean { - const start = feature.properties.time_start; - const end = feature.properties.time_end; - if (typeof start === "number" && Number.isFinite(start) && year < start) return false; - if (typeof end === "number" && Number.isFinite(end) && year > end) return false; - return true; -} - -function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) { - const list = Array.isArray(input) ? input : []; - const normalized = list - .filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0) - .filter((w) => { - if (w.source === "ref") return true; - if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true; - const title = typeof w.title === "string" ? w.title.trim() : ""; - const doc = typeof w.doc === "string" ? w.doc.trim() : ""; - return title.length > 0 || (w.doc !== null && doc.length > 0); - }) - .map((w) => ({ - id: w.id, - source: w.source, - title: typeof w.title === "string" ? w.title.trim() : "", - slug: typeof w.slug === "string" ? w.slug : null, - doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null, - })) - .sort((a, b) => a.id.localeCompare(b.id)); - return normalized; -} - -function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) { - const list = Array.isArray(input) ? input : []; - const normalized = list - .filter((e) => e && (typeof e.id === "string" || typeof e.id === "number")) - .map((e) => ({ - id: String(e.id), - source: e.source, - name: typeof e.name === "string" ? e.name.trim() : "", - description: e.description == null ? null : String(e.description), - })) - .sort((a, b) => a.id.localeCompare(b.id)); - return normalized; -} - -function normalizeEntityWikiLinksForCompare(input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined) { - const list = Array.isArray(input) ? input : []; - const normalized = list - .filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string") - .map((l) => ({ - entity_id: l.entity_id, - wiki_id: l.wiki_id, - operation: l.operation === "delete" ? "delete" : "binding", - })) - .sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id)); - return normalized; -} - -function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) { - const list = Array.isArray(input) ? input : []; - return list - .filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0) - .map((replay) => ({ - id: typeof replay.id === "string" ? replay.id : replay.geometry_id, - geometry_id: replay.geometry_id, - target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare( - replay.target_geometry_ids, - replay.geometry_id - ), - detail: Array.isArray(replay.detail) ? replay.detail : [], - })) - .sort((a, b) => a.geometry_id.localeCompare(b.geometry_id)); -} - -function normalizeReplayTargetGeometryIdsForCompare( - input: string[] | null | undefined, - geometryId: string -) { - const orderedIds: string[] = []; - const seen = new Set(); - - const pushId = (rawId: string | number | null | undefined) => { - if (rawId == null) return; - const id = String(rawId).trim(); - if (!id || seen.has(id)) return; - seen.add(id); - orderedIds.push(id); - }; - - pushId(geometryId); - for (const rawId of input || []) pushId(rawId); - return orderedIds; -} - -function normalizeGeoSearchGeometry(value: unknown): Geometry | null { - if (!value || typeof value !== "object") return null; - const g = value as Record; - if (typeof g.type !== "string") return null; - if (!("coordinates" in g)) return null; - return value as Geometry; -} - -function normalizeGeoSearchBindingIds(value: unknown): string[] { - if (!Array.isArray(value)) return []; - const deduped: string[] = []; - const seen = new Set(); - for (const rawId of value) { - if (typeof rawId !== "string" && typeof rawId !== "number") continue; - const id = String(rawId).trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push(id); + const entityById = new globalThis.Map(); + for (const entity of entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + entityById.set(id, entity); } - return deduped; + + return { + ...draft, + features: draft.features.map((feature) => { + const entityIds = normalizeFeatureEntityIds(feature); + if (!entityIds.length) return feature; + + const candidates = entityIds.map((id) => { + const entity = entityById.get(id) || null; + const name = String(entity?.name || id).trim(); + if (!name) return null; + return { + id, + name, + time_start: entity?.time_start ?? null, + time_end: entity?.time_end ?? null, + }; + }).filter((candidate) => candidate !== null); + + return { + ...feature, + properties: { + ...feature.properties, + entity_name: candidates[0]?.name || null, + entity_names: candidates.map((candidate) => candidate.name), + entity_label_candidates: candidates, + }, + }; + }), + }; +} + +function parseOptionalEntityYearInput(value: string, fieldName: string): number | undefined { + const trimmed = String(value || "").trim(); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error(`${fieldName} phải là số nguyên.`); + } + return parsed; } diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index cff2e76..3fb81e9 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -1,7 +1,131 @@ -import { redirect } from "next/navigation"; +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ApiError } from "@/uhm/api/http"; +import { fetchProjects, type Project } from "@/uhm/api/projects"; export default function EditorIndexPage() { - // Editor must be opened from a specific project (see /user/projects). - redirect("/user/projects"); -} + const router = useRouter(); + // State danh sách project mà user hiện tại có quyền mở trong editor. + const [projects, setProjects] = useState([]); + // State loading cho lần tải đầu của route /editor. + const [isLoading, setIsLoading] = useState(true); + // State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ. + const [error, setError] = useState(null); + // Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm. + const sortedProjects = useMemo(() => { + return [...projects].sort((a, b) => { + const aTime = Date.parse(a.updated_at || a.created_at || ""); + const bTime = Date.parse(b.updated_at || b.created_at || ""); + return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0); + }); + }, [projects]); + + // Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ. + useEffect(() => { + let disposed = false; + + async function loadProjects() { + try { + setIsLoading(true); + setError(null); + const rows = await fetchProjects(); + if (!disposed) setProjects(rows || []); + } catch (err) { + if (disposed) return; + if (err instanceof ApiError && err.status === 401) { + router.replace("/signin"); + return; + } + setError(err instanceof Error ? err.message : "Không tải được danh sách project."); + } finally { + if (!disposed) setIsLoading(false); + } + } + + void loadProjects(); + return () => { + disposed = true; + }; + }, [router]); + + return ( +
+
+
+
+

Editor

+

+ Chọn project để mở route /editor/[id]. +

+
+ +
+ +
+ {isLoading ? ( +
Đang tải project...
+ ) : error ? ( +
{error}
+ ) : sortedProjects.length === 0 ? ( +
+ Chưa có project. Vào trang quản lý project để tạo mới. +
+ ) : ( +
+ {sortedProjects.map((project) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 17ee1a8..ca3f0e7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -249,6 +249,10 @@ export default function Page() { const activeEntityGeometries = activeEntityId ? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION : EMPTY_FEATURE_COLLECTION; + const mapLabelContextDraft = useMemo( + () => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById), + [data, relations.entitiesById, relations.geometryEntityIds] + ); const activeWiki = useMemo(() => { if (!activeWikiSlug) return null; @@ -466,6 +470,8 @@ export default function Page() { ) { } } +function buildEntityLabelContextDraft( + draft: FeatureCollection, + geometryEntityIds: Record, + entitiesById: Record +): FeatureCollection { + if (!draft.features.length) return draft; + + return { + ...draft, + features: draft.features.map((feature) => { + const entityIds = geometryEntityIds[String(feature.properties.id)] || []; + if (!entityIds.length) return feature; + + const candidates = entityIds.map((id) => { + const entity = entitiesById[id] || null; + const name = String(entity?.name || id).trim(); + if (!name) return null; + return { + id, + name, + time_start: entity?.time_start ?? null, + time_end: entity?.time_end ?? null, + }; + }).filter((candidate) => candidate !== null); + + return { + ...feature, + properties: { + ...feature.properties, + entity_id: entityIds[0] || null, + entity_ids: entityIds, + entity_name: candidates[0]?.name || null, + entity_names: candidates.map((candidate) => candidate.name), + entity_label_candidates: candidates, + }, + }; + }), + }; +} + function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; if (value < min) return min; diff --git a/src/uhm/components/Editor.tsx b/src/uhm/components/Editor.tsx index c724149..19e546e 100644 --- a/src/uhm/components/Editor.tsx +++ b/src/uhm/components/Editor.tsx @@ -63,19 +63,24 @@ export default function Editor({ undoStack, width = 280, }: 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); }; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index b4f99c8..2e5961c 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -11,6 +11,7 @@ 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"; export type MapHoverPayload = { featureId: string | number; @@ -39,8 +40,10 @@ type MapProps = { onSelectFeatureIds: (ids: (string | number)[]) => void; onSetMode?: (mode: EditorMode, featureId?: string | number) => void; labelContextDraft?: FeatureCollection; + labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number) => void; + onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; respectBindingFilter?: boolean; @@ -52,6 +55,8 @@ type MapProps = { focusFeatureCollection?: FeatureCollection | null; focusRequestKey?: string | number | null; focusPadding?: number | import("maplibre-gl").PaddingOptions; + imageOverlay?: MapImageOverlay | null; + onImageOverlayChange?: (overlay: MapImageOverlay) => void; }; const Map = forwardRef(function Map({ @@ -63,8 +68,10 @@ const Map = forwardRef(function Map({ selectedFeatureIds, onSelectFeatureIds, labelContextDraft, + labelTimelineYear, onCreateFeature, onDeleteFeature, + onHideFeature, onUpdateFeature, allowGeometryEditing = true, respectBindingFilter = true, @@ -76,15 +83,31 @@ const Map = forwardRef(function Map({ focusFeatureCollection = null, focusRequestKey = null, focusPadding, + imageOverlay = null, + onImageOverlayChange, }, ref) { + // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); + // Ref giữ draft mới nhất để engine đọc không bị stale closure. const draftRef = useRef(draft); + // 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 hover mới nhất cho tooltip/panel ngoài map. const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); + // Ref callback create mới nhất khi drawing engine tạo feature. const onCreateRef = useRef(onCreateFeature); + // Ref callback delete mới nhất khi editing engine xóa feature. const onDeleteRef = useRef(onDeleteFeature); + // Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map. + const onHideRef = useRef(onHideFeature); + // Ref callback update mới nhất khi editing engine đổi geometry. const onUpdateRef = useRef(onUpdateFeature); + // Ref giữ overlay mới nhất cho right-drag controls. + const imageOverlayRef = useRef(imageOverlay); + // Ref callback update overlay mới nhất để interaction không stale. + const onImageOverlayChangeRef = useRef(onImageOverlayChange); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); @@ -93,8 +116,12 @@ const Map = forwardRef(function Map({ useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); + useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); + useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); + useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); + // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { mapRef, containerRef, @@ -107,14 +134,18 @@ const Map = forwardRef(function Map({ 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, }), [getViewState, mapRef]); + // Hook gắn/dọn các interaction vẽ, chọn, sửa geometry. const { editingEngineRef, setupMapInteractions, @@ -130,18 +161,22 @@ const Map = forwardRef(function Map({ onSetModeRef, onCreateRef, onDeleteRef, + onHideRef, onUpdateRef, onHoverFeatureChangeRef, }); + // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. const { applyDraftToMap, applyHighlightToMap, + applyImageOverlayToMap, tryCenterToUserLocation, } = useMapSync({ mapRef, draft, labelContextDraft, + labelTimelineYear, backgroundVisibility, geometryVisibility, selectedFeatureIds, @@ -152,6 +187,7 @@ const Map = forwardRef(function Map({ focusFeatureCollection, focusRequestKey, focusPadding, + imageOverlay, allowGeometryEditing, editingEngineRef, geolocationCenteredRef, @@ -162,6 +198,7 @@ const Map = forwardRef(function Map({ if (!map || !isMapLoaded) return; setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); + applyImageOverlayToMap(); setupMapInteractions(map); applyDraftToMap(draftRef.current); tryCenterToUserLocation(); @@ -180,6 +217,17 @@ const Map = forwardRef(function Map({ } }, [mode, isMapLoaded, mapRef]); + 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 (
@@ -320,6 +368,26 @@ const Map = forwardRef(function Map({ max={zoomBounds.max} step={0.1} value={zoomLevel} + 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))} style={{ flex: 1, diff --git a/src/uhm/components/editor/GeometryBindingPanel.tsx b/src/uhm/components/editor/GeometryBindingPanel.tsx index ed30065..b21330e 100644 --- a/src/uhm/components/editor/GeometryBindingPanel.tsx +++ b/src/uhm/components/editor/GeometryBindingPanel.tsx @@ -8,6 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore"; type GeometryChoice = { id: string; label?: string; + time_start?: number | null; + time_end?: number | null; + isTimelineVisible?: boolean; isNew?: boolean; }; @@ -31,16 +34,16 @@ export default function GeometryBindingPanel({ statusText, bindingFilterEnabled, setGeometryBindingFilterEnabled, - hoveredGeometryId, - setHoveredGeometryId, + geometryVisibility, + setGeometryVisibility, } = useEditorStore( useShallow((state) => ({ selectedFeatureIds: state.selectedFeatureIds, statusText: state.geoBindingStatus, bindingFilterEnabled: state.geometryBindingFilterEnabled, setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled, - hoveredGeometryId: state.hoveredGeometryId, - setHoveredGeometryId: state.setHoveredGeometryId, + geometryVisibility: state.geometryVisibility, + setGeometryVisibility: state.setGeometryVisibility, })) ); const effectiveSelectedGeometryId = @@ -55,7 +58,14 @@ export default function GeometryBindingPanel({ 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(), isNew: Boolean(g.isNew) })); + .map((g) => ({ + id: g.id.trim(), + label: (g.label || "").trim(), + time_start: typeof g.time_start === "number" ? g.time_start : null, + time_end: typeof g.time_end === "number" ? g.time_end : null, + isTimelineVisible: Boolean(g.isTimelineVisible), + isNew: Boolean(g.isNew), + })); cleaned.sort((a, b) => a.id.localeCompare(b.id)); return cleaned; }, [geometries]); @@ -80,15 +90,20 @@ export default function GeometryBindingPanel({ if (!canFocusGeometry) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - setHoveredGeometryId((current) => (current === geometryId ? null : current)); onFocusGeometry?.(geometryId); }; const handleFocusGeometry = (geometryId: string) => { - setHoveredGeometryId((current) => (current === geometryId ? null : current)); onFocusGeometry?.(geometryId); }; + const toggleGeometryVisibility = (geometryId: string) => { + setGeometryVisibility((prev) => ({ + ...prev, + [geometryId]: prev[geometryId] === false, + })); + }; + return (
setHoveredGeometryId(null)} >
@@ -148,31 +162,28 @@ export default function GeometryBindingPanel({
{collapsed ? null : selectedGeometry ? ( + (() => { + const isHidden = geometryVisibility[selectedGeometry.id] === false; + const idColor = getGeometryIdColor(selectedGeometry); + const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb"; + return (
handleFocusGeometry(selectedGeometry.id)} onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)} - onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)} >
{selectedGeometry.label || selectedGeometry.id} + {isHidden ? hidden : null} {selectedGeometry.isNew ? : null} +
+ ); + })() ) : null} {collapsed ? null : rows.length ? ( @@ -221,36 +247,33 @@ export default function GeometryBindingPanel({ {visibleRows .map((g) => { const isBound = bindingSet.has(g.id); - const isHovered = hoveredGeometryId === g.id; + const isHidden = geometryVisibility[g.id] === false; + const idColor = getGeometryIdColor(g); + const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb"; return (
handleFocusGeometry(g.id)} onKeyDown={(event) => handleFocusKeyDown(event, g.id)} - onMouseEnter={() => setHoveredGeometryId(g.id)} >
{g.label || g.id} + {isHidden ? hidden : null} {isBound ? bound : null} {g.isNew ? : null}
+ + {canBindToggle ? ( + ) : null} +
+ +
+ + + +
+ + {overlay ? ( +
+
+ {overlay.name} +
+ +
+ ) : ( +
+ Chưa có ảnh overlay. +
+ )} + + ); +} + +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; diff --git a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx index 5a4476f..b841ad2 100644 --- a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx +++ b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx @@ -8,8 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore"; type Props = { onCreateEntityOnly: () => void; - onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => 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; }; @@ -17,6 +18,7 @@ export default function ProjectEntityRefsPanel({ onCreateEntityOnly, onUpdateEntity, hasSelectedGeometry, + selectedGeometryTime, onToggleBindEntityForSelectedGeometry, }: Props) { const { @@ -78,15 +80,35 @@ export default function ProjectEntityRefsPanel({ ); 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", value: string) => { + 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 (
{isNewEntityRef(activeEntity) ? : null}
- +
+ + +
@@ -277,10 +299,31 @@ export default function ProjectEntityRefsPanel({ disabled={isEntitySubmitting} style={entityInputStyle} /> +
+ setEditTimeStart(event.target.value)} + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + setEditTimeEnd(event.target.value)} + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +
+
+ {isCreateOpen ? ( + + ) : null} + +
{isCreateOpen ? ( @@ -356,6 +400,22 @@ export default function ProjectEntityRefsPanel({ disabled={isEntitySubmitting} style={entityInputStyle} /> +
+ handleEntityFormChange("time_start", event.target.value)} + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + handleEntityFormChange("time_end", event.target.value)} + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +