draw path | draw area | localstorage layer state | add entities property parallel | timeline bar

This commit is contained in:
taDuc
2026-04-08 20:03:16 +07:00
parent 5ac5c4c0af
commit 4969c8cc57
15 changed files with 2056 additions and 74 deletions

View File

@@ -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<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })
const [entities, setEntities] = useState<Entity[]>([]);
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
const [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: ENTITY_TYPE_OPTIONS[0].value,
});
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
});
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
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<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => 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 (
<div style={{ display: "flex", minHeight: "100vh" }}>
<Editor
mode={mode}
setMode={setMode}
entities={entities}
selectedEntityId={selectedEntityId}
onSelectEntityId={setSelectedEntityId}
entityStatus={entityStatus}
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
@@ -92,21 +607,329 @@ export default function Page() {
undoStack={editor.undoStack}
/>
<Map
mode={mode}
draft={editor.draft}
onCreateFeature={editor.createFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
<Map
mode={mode}
draft={editor.draft}
selectedEntityId={selectedEntityId}
selectedEntityName={selectedEntity?.name || null}
selectedEntityTypeId={selectedEntity?.type_id || null}
onSelectFeatureId={setSelectedFeatureId}
onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
/>
<TimelineBar
minYear={timelineRange.min}
maxYear={timelineRange.max}
windowStartYear={timelineWindowStart}
windowEndYear={timelineWindowEnd}
onWindowStartYearChange={handleTimelineWindowStartChange}
onWindowEndYearChange={handleTimelineWindowEndChange}
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
/>
</div>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Vào mode Select chọn 1 geometry đ điền entity.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)}
</div>
<select
multiple
value={selectedGeometryEntityIds}
onChange={(event) =>
handleEntityIdsChange(
Array.from(event.target.selectedOptions, (option) => option.value)
)
}
disabled={isEntitySubmitting}
style={{ ...entityInputStyle, minHeight: "96px" }}
>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
{entity.type_id ? ` [${entity.type_id}]` : ""}
</option>
))}
</select>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
</div>
<input
value={entityForm.name}
onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => handleEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={entityForm.type_id}
onChange={(event) => handleEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{ENTITY_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={geometryMetaForm.time_start}
onChange={(event) => handleGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => handleGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
onClick={handleCreateEntityAndAttach}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Tạo Entity + Gắn
</button>
<button
onClick={handleApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Áp dụng Entities + Metadata
</button>
{editor.changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
{entityFormStatus}
</div>
) : null}
</div>
)}
</div>
}
/>
</div>
);
}
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<string>();
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<string, unknown>;
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;
}