From 4969c8cc57e21dffc2dcd15a74914a98b2cdd7d8 Mon Sep 17 00:00:00 2001 From: taDuc Date: Wed, 8 Apr 2026 20:03:16 +0700 Subject: [PATCH] draw path | draw area | localstorage layer state | add entities property parallel | timeline bar --- api/config.ts | 1 + api/entities.ts | 48 ++ api/geometries.ts | 10 +- app/page.tsx | 867 ++++++++++++++++++++++++++- components/BackgroundLayersPanel.tsx | 5 + components/Editor.tsx | 82 ++- components/Map.tsx | 414 ++++++++++++- components/TimelineBar.tsx | 269 +++++++++ data | 1 - lib/circleEngine.ts | 226 +++++++ lib/drawingEngine.ts | 2 +- lib/pathEngine.ts | 129 ++++ lib/pointEngine.ts | 2 +- lib/selectingEngine.ts | 23 +- lib/useEditorState.ts | 51 +- 15 files changed, 2056 insertions(+), 74 deletions(-) create mode 100644 api/entities.ts create mode 100644 components/TimelineBar.tsx delete mode 160000 data create mode 100644 lib/circleEngine.ts create mode 100644 lib/pathEngine.ts diff --git a/api/config.ts b/api/config.ts index 605dcc2..fc1380e 100644 --- a/api/config.ts +++ b/api/config.ts @@ -6,6 +6,7 @@ export const API_BASE_URL = export const API_ENDPOINTS = { geometries: `${API_BASE_URL}/geometries`, geometriesBatch: `${API_BASE_URL}/geometries/batch`, + entities: `${API_BASE_URL}/entities`, vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`, rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`, vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`, diff --git a/api/entities.ts b/api/entities.ts new file mode 100644 index 0000000..6f56125 --- /dev/null +++ b/api/entities.ts @@ -0,0 +1,48 @@ +import { API_ENDPOINTS } from "@/api/config"; +import { requestJson } from "@/api/http"; + +export type Entity = { + id: string; + name: string; + slug?: string | null; + description?: string | null; + type_id?: string | null; + status?: number | null; + geometry_count?: number; + created_at?: string; + updated_at?: string; +}; + +export type CreateEntityPayload = { + name: string; + slug?: string | null; + description?: string | null; + type_id?: string | null; + status?: number | null; +}; + +export async function fetchEntities(query?: { q?: string }): Promise { + const params = new URLSearchParams(); + if (query?.q) { + params.set("q", query.q); + } + const suffix = params.toString(); + const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities; + return requestJson(url); +} + +export async function createEntity(payload: CreateEntityPayload): Promise { + return requestJson(API_ENDPOINTS.entities, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function updateEntity(id: string, payload: CreateEntityPayload): Promise { + return requestJson(`${API_ENDPOINTS.entities}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} diff --git a/api/geometries.ts b/api/geometries.ts index 22e1167..67027fe 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -8,19 +8,23 @@ export type GeometriesBBoxQuery = { maxLng: number; maxLat: number; time?: number; + entity_id?: string; }; export type GeometryCreatePayload = { geometry: Geometry; time_start?: number | null; time_end?: number | null; - kind?: string | null; + entity_id?: string | null; + entity_ids?: string[]; }; export type GeometryUpdatePayload = { geometry: Geometry; time_start?: number | null; time_end?: number | null; + entity_id?: string | null; + entity_ids?: string[]; }; export type GeometryCreateResponse = { @@ -44,6 +48,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string { query.set("time", String(params.time)); } + if (params.entity_id) { + query.set("entity_id", params.entity_id); + } + return query.toString(); } diff --git a/app/page.tsx b/app/page.tsx index f27a542..995ccc1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; +import TimelineBar from "@/components/TimelineBar"; +import { createEntity, Entity, fetchEntities } from "@/api/entities"; import { ApiError } from "@/api/http"; -import { fetchGeometriesByBBox, saveGeometryBatchChanges } from "@/api/geometries"; +import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; import { + Feature, FeatureCollection, useEditorState, } from "@/lib/useEditorState"; import { + BACKGROUND_LAYER_OPTIONS, BackgroundLayerId, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, @@ -18,48 +22,327 @@ import { } from "@/lib/backgroundLayers"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; +const WORLD_BBOX = { + minLng: -180, + minLat: -90, + maxLng: 180, + maxLat: 90, +} as const; +const CURRENT_YEAR = new Date().getUTCFullYear(); +const FALLBACK_TIMELINE_RANGE: TimelineRange = { + min: CURRENT_YEAR - 5000, + max: CURRENT_YEAR + 100, +}; +const TIMELINE_DEBOUNCE_MS = 180; +const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; + +const ENTITY_TYPE_OPTIONS = [ + { value: "country", label: "Country" }, + { value: "castle", label: "Castle" }, + { value: "kingdom", label: "Kingdom" }, + { value: "city", label: "City" }, + { value: "region", label: "Region" }, + { value: "event", label: "Event" }, +] as const; + +type TimelineRange = { + min: number; + max: number; +}; + +type EntityFormState = { + name: string; + slug: string; + type_id: string; +}; + +type GeometryMetaFormState = { + time_start: string; + time_end: string; +}; export default function Page() { - const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle"); + const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle">("idle"); const [initialData, setInitialData] = useState(EMPTY_FC); const [isSaving, setIsSaving] = useState(false); - const [backgroundVisibility, setBackgroundVisibility] = useState( - () => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }) + const [entities, setEntities] = useState([]); + const [selectedEntityId, setSelectedEntityId] = useState(null); + const [entityStatus, setEntityStatus] = useState(null); + const [selectedFeatureId, setSelectedFeatureId] = useState(null); + const [entityForm, setEntityForm] = useState({ + name: "", + slug: "", + type_id: ENTITY_TYPE_OPTIONS[0].value, + }); + const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); + const [geometryMetaForm, setGeometryMetaForm] = useState({ + time_start: "", + time_end: "", + }); + const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); + const [entityFormStatus, setEntityFormStatus] = useState(null); + const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); + const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); + const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); + const [timelineYear, setTimelineYear] = useState(() => + clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); + const [timelineDraftYear, setTimelineDraftYear] = useState(() => + clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + ); + const [isTimelineReady, setIsTimelineReady] = useState(false); + const [isTimelineLoading, setIsTimelineLoading] = useState(false); + const [timelineStatus, setTimelineStatus] = useState(null); + const [backgroundVisibility, setBackgroundVisibility] = useState( + () => loadBackgroundLayerVisibilityFromStorage() + ); + const timelineFetchRequestRef = useRef(0); const editor = useEditorState(initialData); + const selectedEntity = + entities.find((entity) => entity.id === selectedEntityId) || null; + const selectedFeature = + selectedFeatureId === null + ? null + : editor.draft.features.find((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ) || null; useEffect(() => { - async function loadInitial() { + let disposed = false; + + async function loadEntities() { try { - const data = await fetchGeometriesByBBox({ - minLng: -180, - minLat: -90, - maxLng: 180, - maxLat: 90, + const rows = await fetchEntities(); + if (disposed) return; + + setEntities(rows); + setSelectedEntityId((prev) => { + if (prev && rows.some((entity) => entity.id === prev)) { + return prev; + } + return rows[0]?.id || null; }); - setInitialData(data); + setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry."); } catch (err) { - console.error("Load initial data failed", err); + if (disposed) return; + console.error("Load entities failed", err); + setEntityStatus("Không tải được danh sách entity."); } } - loadInitial(); + loadEntities(); + + return () => { + disposed = true; + }; }, []); + useEffect(() => { + if (selectedFeatureId === null) return; + const stillExists = editor.draft.features.some((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ); + if (!stillExists) { + setSelectedFeatureId(null); + } + }, [editor.draft, selectedFeatureId]); + + useEffect(() => { + if (!selectedFeature) { + setEntityForm({ + name: "", + slug: "", + type_id: ENTITY_TYPE_OPTIONS[0].value, + }); + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + time_start: "", + time_end: "", + }); + setEntityFormStatus(null); + return; + } + + const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); + const primaryEntityId = featureEntityIds[0] || null; + const linkedEntity = primaryEntityId + ? entities.find((entity) => entity.id === primaryEntityId) || null + : null; + const nextTypeId = + linkedEntity?.type_id || + selectedFeature.properties.entity_type_id || + ENTITY_TYPE_OPTIONS[0].value; + + setEntityForm({ + name: "", + slug: linkedEntity?.slug || "", + type_id: nextTypeId, + }); + setSelectedGeometryEntityIds(featureEntityIds); + setGeometryMetaForm({ + time_start: selectedFeature.properties.time_start != null + ? String(selectedFeature.properties.time_start) + : "", + time_end: selectedFeature.properties.time_end != null + ? String(selectedFeature.properties.time_end) + : "", + }); + if (!featureEntityIds.length) { + setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); + } else { + setEntityFormStatus(null); + } + }, [selectedFeature, entities]); + + useEffect(() => { + let disposed = false; + + async function loadTimelineBounds() { + setIsTimelineLoading(true); + try { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + }); + if (disposed) return; + + const range = deriveTimelineRange(data); + const initialYear = clampYear(CURRENT_YEAR, range); + + setTimelineRange(range); + setTimelineWindowStart(range.min); + setTimelineWindowEnd(range.max); + setTimelineYear(initialYear); + setTimelineDraftYear(initialYear); + setTimelineStatus(null); + } catch (err) { + if (disposed) return; + console.error("Load timeline bounds failed", err); + const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE); + setTimelineRange(FALLBACK_TIMELINE_RANGE); + setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min); + setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max); + setTimelineYear(fallbackYear); + setTimelineDraftYear(fallbackYear); + setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định."); + } finally { + if (!disposed) { + setIsTimelineLoading(false); + setIsTimelineReady(true); + } + } + } + + loadTimelineBounds(); + + return () => { + disposed = true; + }; + }, []); + + useEffect(() => { + if (!isTimelineReady) return; + + const timeoutId = window.setTimeout(() => { + if (timelineDraftYear !== timelineYear) { + setTimelineYear(timelineDraftYear); + } + }, TIMELINE_DEBOUNCE_MS); + + return () => window.clearTimeout(timeoutId); + }, [timelineDraftYear, timelineYear, isTimelineReady]); + + useEffect(() => { + persistBackgroundLayerVisibility(backgroundVisibility); + }, [backgroundVisibility]); + + useEffect(() => { + const lower = Math.min(timelineWindowStart, timelineWindowEnd); + const upper = Math.max(timelineWindowStart, timelineWindowEnd); + setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper)); + }, [timelineWindowStart, timelineWindowEnd]); + + useEffect(() => { + if (!isTimelineReady) return; + + let disposed = false; + const requestId = ++timelineFetchRequestRef.current; + + async function loadByTimeline() { + setIsTimelineLoading(true); + setTimelineStatus(null); + + try { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timelineYear, + }); + + if (disposed || requestId !== timelineFetchRequestRef.current) return; + setInitialData(data); + } catch (err) { + if (err instanceof ApiError) { + console.error("Load timeline data failed", err.body); + } else { + console.error("Load timeline data failed", err); + } + + if (!disposed && requestId === timelineFetchRequestRef.current) { + setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn."); + } + } finally { + if (!disposed && requestId === timelineFetchRequestRef.current) { + setIsTimelineLoading(false); + } + } + } + + loadByTimeline(); + + return () => { + disposed = true; + }; + }, [timelineYear, isTimelineReady]); + const handleSave = async () => { const payload = editor.buildPayload(); if (!payload.length) return; + + const invalid = payload.find((change) => { + if (change.type === "delete") return false; + const draftFeature = change.type === "create" + ? change.feature + : editor.draft.features.find((feature) => + String(feature.properties.id) === String(change.id) + ); + if (!draftFeature) return false; + const entityIds = normalizeFeatureEntityIds(draftFeature); + return entityIds.length === 0; + }); + + if (invalid) { + const invalidId = invalid.type === "create" + ? invalid.feature.properties.id + : invalid.id; + setSelectedFeatureId(invalidId); + setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity."); + return; + } + setIsSaving(true); + setEntityStatus(null); try { await saveGeometryBatchChanges(payload); editor.clearChanges(); + await reloadCurrentTimelineData(); } catch (err) { if (err instanceof ApiError) { console.error("Save failed", err.body); + setEntityStatus(`Save thất bại: ${err.body}`); return; } console.error("Save error", err); + setEntityStatus("Save thất bại."); } finally { setIsSaving(false); } @@ -80,11 +363,243 @@ export default function Page() { setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }); }; + const handleTimelineWindowStartChange = (nextYear: number) => { + const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); + setTimelineWindowStart(Math.min(next, timelineWindowEnd)); + }; + + const handleTimelineWindowEndChange = (nextYear: number) => { + const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); + setTimelineWindowEnd(Math.max(next, timelineWindowStart)); + }; + + const handleTimelineYearChange = (nextYear: number) => { + const lower = Math.min(timelineWindowStart, timelineWindowEnd); + const upper = Math.max(timelineWindowStart, timelineWindowEnd); + setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); + }; + + const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { + setEntityForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleGeometryMetaFormChange = (key: "time_start" | "time_end", value: string) => { + setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleEntityIdsChange = (values: string[]) => { + setSelectedGeometryEntityIds(uniqueEntityIds(values)); + }; + + const reloadEntities = async () => { + const rows = await fetchEntities(); + setEntities(rows); + setSelectedEntityId((prev) => { + if (prev && rows.some((entity) => entity.id === prev)) { + return prev; + } + return rows[0]?.id || null; + }); + return rows; + }; + + const reloadCurrentTimelineData = async () => { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timelineYear, + }); + setInitialData(data); + }; + + const parseGeometryMetaFormRange = () => { + const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); + const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); + if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { + throw new Error("time_start phải <= time_end."); + } + return { timeStart, timeEnd }; + }; + + const patchSelectedFeatureLocally = ( + feature: Feature, + entityIds: string[], + timeStart: number | null, + timeEnd: number | null, + entityRows: Entity[] = entities + ) => { + const primaryEntityId = entityIds[0] || null; + const primaryEntity = primaryEntityId + ? entityRows.find((entity) => entity.id === primaryEntityId) || null + : null; + const entityNames = entityIds + .map((id) => entityRows.find((entity) => entity.id === id)?.name || "") + .filter((name) => name.length > 0); + + editor.patchFeatureProperties(feature.properties.id, { + entity_id: primaryEntityId, + entity_ids: entityIds, + entity_name: primaryEntity?.name || null, + entity_names: entityNames, + entity_type_id: primaryEntity?.type_id || null, + time_start: timeStart, + time_end: timeEnd, + }); + + setSelectedGeometryEntityIds(entityIds); + setGeometryMetaForm({ + time_start: timeStart != null ? String(timeStart) : "", + time_end: timeEnd != null ? String(timeEnd) : "", + }); + }; + + const handleApplyEntitiesForSelectedGeometry = async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + if (!entityIds.length) { + setEntityFormStatus("Geometry phải có ít nhất 1 entity."); + return; + } + + let timeStart: number | null; + let timeEnd: number | null; + try { + ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + if (editor.hasPersistedFeature(selectedFeature.properties.id)) { + if (editor.changeCount > 0) { + setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại."); + return; + } + + await updateGeometry(selectedFeature.properties.id, { + geometry: selectedFeature.geometry, + time_start: timeStart, + time_end: timeEnd, + entity_ids: entityIds, + }); + + await reloadCurrentTimelineData(); + setEntityFormStatus("Đã cập nhật entities + metadata geometry."); + } else { + patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd); + setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới."); + } + } catch (err) { + if (err instanceof ApiError) { + setEntityFormStatus(`Lưu thất bại: ${err.body}`); + } else { + setEntityFormStatus("Lưu thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }; + + const handleCreateEntityAndAttach = async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) { + setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại."); + return; + } + + const name = entityForm.name.trim(); + if (!name) { + setEntityFormStatus("Tên entity là bắt buộc."); + return; + } + + let timeStart: number | null; + let timeEnd: number | null; + try { + ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + const created = await createEntity({ + name, + slug: entityForm.slug.trim() || null, + type_id: entityForm.type_id || ENTITY_TYPE_OPTIONS[0].value, + }); + + const rows = await reloadEntities(); + const nextEntityIds = uniqueEntityIds([ + ...selectedGeometryEntityIds, + created.id, + ]); + + if (editor.hasPersistedFeature(selectedFeature.properties.id)) { + await updateGeometry(selectedFeature.properties.id, { + geometry: selectedFeature.geometry, + time_start: timeStart, + time_end: timeEnd, + entity_ids: nextEntityIds, + }); + await reloadCurrentTimelineData(); + setEntityFormStatus("Đã tạo entity và gắn vào geometry."); + } else { + patchSelectedFeatureLocally(selectedFeature, nextEntityIds, timeStart, timeEnd, rows); + setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới."); + } + + setSelectedEntityId(created.id); + setEntityForm((prev) => ({ + ...prev, + name: "", + slug: "", + })); + } catch (err) { + if (err instanceof ApiError) { + setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`); + } else { + setEntityFormStatus("Tạo/gắn entity thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }; + + const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0; + const timelineStatusText = + editor.changeCount > 0 + ? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian." + : isSaving + ? "Đang lưu thay đổi..." + : timelineStatus; + + const handleCreateFeature = (feature: Feature) => { + editor.createFeature(feature); + setSelectedFeatureId(feature.properties.id); + }; + return (
- +
+ + +
+
+ Selected Geometry +
+ {!selectedFeature ? ( +
+ Vào mode Select và chọn 1 geometry để điền entity. +
+ ) : ( +
+
+ ID: {String(selectedFeature.properties.id)} +
+
+ Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)} +
+ + +
+ Geometry phải có ít nhất 1 entity để Save. +
+ + handleEntityFormChange("name", event.target.value)} + placeholder="Tên entity mới" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + handleEntityFormChange("slug", event.target.value)} + placeholder="Slug" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + + handleGeometryMetaFormChange("time_start", event.target.value)} + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + handleGeometryMetaFormChange("time_end", event.target.value)} + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + + + + + {editor.changeCount > 0 ? ( +
+ Geometry mới sẽ lưu entity khi bấm Save. +
+ ) : null} + + {entityFormStatus ? ( +
+ {entityFormStatus} +
+ ) : null} +
+ )} +
+ } /> ); } + +const entityInputStyle: CSSProperties = { + width: "100%", + borderRadius: "6px", + border: "1px solid #334155", + background: "#111827", + color: "#f8fafc", + padding: "6px 8px", + fontSize: "13px", +}; + +function deriveTimelineRange(collection: FeatureCollection): TimelineRange { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const feature of collection.features) { + const { time_start, time_end } = feature.properties; + + if (isYearNumber(time_start)) { + min = Math.min(min, time_start); + max = Math.max(max, time_start); + } + + if (isYearNumber(time_end)) { + min = Math.min(min, time_end); + max = Math.max(max, time_end); + } + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return FALLBACK_TIMELINE_RANGE; + } + + return { + min: Math.floor(min), + max: Math.ceil(max), + }; +} + +function clampYear(year: number, range: TimelineRange): number { + return clampYearValue(year, range.min, range.max); +} + +function clampYearValue(year: number, minYear: number, maxYear: number): number { + const lower = Math.min(minYear, maxYear); + const upper = Math.max(minYear, maxYear); + if (year < lower) return lower; + if (year > upper) return upper; + return year; +} + +function isYearNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function parseOptionalYearInput(raw: string, fieldName: string): number | null { + const value = raw.trim(); + if (!value.length) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} phải là số.`); + } + return Math.trunc(parsed); +} + +function normalizeFeatureEntityIds(feature: Feature): string[] { + const fromArray = Array.isArray(feature.properties.entity_ids) + ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; + + if (fromArray.length) { + return uniqueEntityIds(fromArray); + } + + const single = feature.properties.entity_id; + if (typeof single === "string" && single.trim().length > 0) { + return [single.trim()]; + } + + return []; +} + +function uniqueEntityIds(ids: string[]): string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const rawId of ids) { + const id = rawId.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push(id); + } + return deduped; +} + +function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string { + const entityIds = normalizeFeatureEntityIds(feature); + if (!entityIds.length) return "Chưa gắn"; + + const names = entityIds + .map((id) => entities.find((entity) => entity.id === id)?.name || id) + .filter((name) => name.trim().length > 0); + return names.join(", "); +} + +function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { + if (typeof window === "undefined") { + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } + + try { + const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY); + if (!raw) { + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } + + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeBackgroundLayerVisibility(parsed); + return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } catch (err) { + console.warn("Load background layer visibility from storage failed", err); + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } +} + +function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY, + JSON.stringify(visibility) + ); + } catch (err) { + console.warn("Persist background layer visibility failed", err); + } +} + +function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { + if (!raw || typeof raw !== "object") return null; + + const source = raw as Record; + const next: BackgroundLayerVisibility = { + ...DEFAULT_BACKGROUND_LAYER_VISIBILITY, + }; + + for (const layer of BACKGROUND_LAYER_OPTIONS) { + const value = source[layer.id]; + if (typeof value === "boolean") { + next[layer.id] = value; + } + } + + return next; +} diff --git a/components/BackgroundLayersPanel.tsx b/components/BackgroundLayersPanel.tsx index 7f0c0a4..f392c8b 100644 --- a/components/BackgroundLayersPanel.tsx +++ b/components/BackgroundLayersPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { ReactNode } from "react"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerId, @@ -11,6 +12,7 @@ type Props = { onToggleLayer: (id: BackgroundLayerId) => void; onShowAll: () => void; onHideAll: () => void; + topContent?: ReactNode; }; export default function BackgroundLayersPanel({ @@ -18,6 +20,7 @@ export default function BackgroundLayersPanel({ onToggleLayer, onShowAll, onHideAll, + topContent, }: Props) { return (