Files
History-client/app/editor/page.tsx
2026-04-19 00:13:22 +07:00

1610 lines
59 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Map from "@/components/Map";
import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import TimelineBar from "@/components/TimelineBar";
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
import { Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox } from "@/api/geometries";
import {
createSection,
createSectionCommit,
fetchSections,
fetchSectionCommits,
openSectionEditor,
restoreSectionCommit,
Section,
SectionCommit,
SectionState,
submitSection,
} from "@/api/sections";
import {
Change,
Feature,
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import {
DEFAULT_ENTITY_TYPE_ID,
ENTITY_TYPE_OPTIONS,
EntityTypeGroupId,
findEntityTypeOption,
} from "@/lib/entityTypeOptions";
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 DEFAULT_SECTION_TITLE = "Main editor section";
const DEFAULT_EDITOR_USER_ID = "local-editor";
type TimelineRange = {
min: number;
max: number;
};
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
type PendingEntityCreate = {
id: string;
name: string;
slug: string | null;
type_id: string;
status: number;
};
type EditorSnapshot = {
schema_version: number;
section: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: Array<Record<string, unknown>>;
geometries?: Array<Record<string, unknown>>;
link_scopes?: Array<Record<string, unknown>>;
};
export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isOpeningSection, setIsOpeningSection] = useState(false);
const [availableSections, setAvailableSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState("");
const [newSectionTitle, setNewSectionTitle] = useState("");
const [commitTitle, setCommitTitle] = useState("");
const [commitNote, setCommitNote] = useState("");
const [editorUserIdInput, setEditorUserIdInput] = useState(DEFAULT_EDITOR_USER_ID);
const [activeSection, setActiveSection] = useState<Section | null>(null);
const [sectionState, setSectionState] = useState<SectionState | null>(null);
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
const [createdEntities, setCreatedEntities] = useState<Array<{
id: string;
name: string;
type_id?: string | 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: DEFAULT_ENTITY_TYPE_ID,
});
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
const [entitySearchQuery, setEntitySearchQuery] = useState("");
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
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>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const timelineYearRef = useRef(timelineYear);
const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID);
const editor = useEditorState(initialData);
const editorUserId = normalizeEditorUserId(editorUserIdInput);
const entities = useMemo(
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
);
const selectedFeature =
selectedFeatureId === null
? null
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
) || null;
const createdGeometries = useMemo(() => {
const rows: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}> = [];
for (const change of editor.changes.values()) {
if (change.action !== "create") continue;
const feature = change.feature;
const entityNames = normalizeFeatureEntityIds(feature)
.map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId);
rows.push({
id: feature.properties.id,
geometryType: feature.geometry.type,
semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature),
entityNames,
});
}
return rows;
}, [editor.changes, entities]);
useEffect(() => {
timelineYearRef.current = timelineYear;
}, [timelineYear]);
useEffect(() => {
editorUserIdRef.current = editorUserId;
}, [editorUserId]);
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId, editorUserIdRef.current);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
const commits = await fetchSectionCommits(sectionId);
let nextInitialData = snapshot?.editor_feature_collection || null;
if (!nextInitialData) {
nextInitialData = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYearRef.current,
});
}
setActiveSection(editorPayload.section);
setSelectedSectionId(editorPayload.section.id);
setSectionState(editorPayload.state);
setLastSectionSnapshot(snapshot);
setInitialData(nextInitialData);
setSectionCommits(commits);
setPendingEntityCreates([]);
setCreatedEntities([]);
setSelectedFeatureId(null);
setEntityFormStatus(null);
}, []);
useEffect(() => {
let disposed = false;
async function loadSection() {
try {
setIsOpeningSection(true);
const sections = await fetchSections();
if (disposed) return;
setAvailableSections(sections);
const section = await resolveDefaultSection(sections, editorUserIdRef.current);
if (disposed) return;
setAvailableSections((prev) =>
prev.some((item) => item.id === section.id)
? prev
: [section, ...prev]
);
try {
await openSectionForEditing(section.id);
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 409) {
throw err;
}
const fallbackSection = await resolveOpenableSectionAfterConflict(
sections,
section.id,
editorUserIdRef.current
);
if (disposed) return;
setAvailableSections((prev) =>
prev.some((item) => item.id === fallbackSection.id)
? prev
: [fallbackSection, ...prev]
);
await openSectionForEditing(fallbackSection.id);
if (!disposed) {
setEntityStatus("Section mặc định đang bị lock, đã mở section khác để chỉnh sửa.");
}
}
} catch (err) {
if (disposed) return;
if (err instanceof ApiError) {
setEntityStatus(`Không tải được section editor: ${err.body || err.message}`);
} else {
console.error("Load section editor failed", err);
setEntityStatus("Không tải được section editor.");
}
} finally {
if (!disposed) {
setIsOpeningSection(false);
}
}
}
loadSection();
return () => {
disposed = true;
};
}, [openSectionForEditing]);
useEffect(() => {
let disposed = false;
async function loadEntities() {
try {
const rows = await fetchEntities();
if (disposed) return;
setPersistedEntities(rows);
setEntityStatus(null);
} catch (err) {
if (disposed) return;
console.error("Load entities failed", err);
setEntityStatus("Không tải được danh sách entity.");
}
}
loadEntities();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!selectedFeature) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
const keyword = entitySearchQuery.trim();
if (!keyword.length) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
let disposed = false;
const requestId = ++entitySearchRequestRef.current;
const timeoutId = window.setTimeout(async () => {
setIsEntitySearchLoading(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
const pendingMatches = pendingEntityCreates
.filter((entity) =>
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
)
.map<Entity>((entity) => ({
id: entity.id,
name: entity.name,
slug: entity.slug,
type_id: entity.type_id,
status: entity.status,
geometry_count: 0,
}));
const mergedRows = mergeEntitySearchResults(rows, pendingMatches);
setEntitySearchResults(mergedRows);
setSelectedSearchEntityId((prev) =>
prev && mergedRows.some((entity) => entity.id === prev)
? prev
: mergedRows[0]?.id || null
);
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
const pendingMatches = pendingEntityCreates
.filter((entity) =>
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
)
.map<Entity>((entity) => ({
id: entity.id,
name: entity.name,
slug: entity.slug,
type_id: entity.type_id,
status: entity.status,
geometry_count: 0,
}));
setEntitySearchResults(pendingMatches);
setSelectedSearchEntityId(pendingMatches[0]?.id || null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature, pendingEntityCreates]);
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) {
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
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)
: "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
}, [selectedFeature]);
useEffect(() => {
if (!selectedFeature) return;
const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature);
const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) =>
allowedGroupIds.includes(option.groupId)
);
if (!fallbackOption) return;
setEntityForm((prev) => {
const currentOption = findEntityTypeOption(prev.type_id);
const isCurrentAllowed = currentOption
? allowedGroupIds.includes(currentOption.groupId)
: false;
if (isCurrentAllowed || prev.type_id === fallbackOption.value) {
return prev;
}
return {
...prev,
type_id: fallbackOption.value,
};
});
}, [selectedFeature]);
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(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, []);
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;
if (!lastSectionSnapshot?.editor_feature_collection) {
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, lastSectionSnapshot]);
const handleCommitSection = async () => {
if (!activeSection || !sectionState) {
setEntityStatus("Chưa mở được section editor.");
return;
}
const geometryChanges = editor.buildPayload();
setIsSaving(true);
setEntityStatus(null);
try {
const snapshot = buildEditorSnapshot({
section: activeSection,
draft: editor.draft,
changes: geometryChanges,
pendingEntities: pendingEntityCreates,
previousSnapshot: lastSectionSnapshot,
hasPersistedFeature: editor.hasPersistedFeature,
});
const result = await createSectionCommit(activeSection.id, {
snapshot,
created_by: editorUserId,
expected_version: sectionState.version,
expected_head_commit_id: sectionState.head_commit_id,
title: commitTitle.trim() || `Commit ${new Date().toLocaleString()}`,
note: commitNote.trim() || null,
});
setSectionState(result.state);
setLastSectionSnapshot(snapshot);
setInitialData(editor.draft);
editor.clearChanges();
setPendingEntityCreates([]);
setCreatedEntities([]);
setCommitTitle("");
setCommitNote("");
setSectionCommits(await fetchSectionCommits(activeSection.id));
setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
console.error("Commit failed", err.body);
setEntityStatus(`Commit thất bại: ${err.body}`);
return;
}
console.error("Commit error", err);
setEntityStatus("Commit thất bại.");
} finally {
setIsSaving(false);
}
};
const updateBackgroundVisibility = (
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...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" | "binding", value: string) => {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
};
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddSelectedSearchEntity = () => {
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
if (!entityId.length) {
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
return;
}
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
setSelectedGeometryEntityIds(next);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
};
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 resolveGeometryTypeFromEntityIds = (
entityIds: string[],
entityRows: Entity[] = entities
): string | null => {
const primaryEntityId = entityIds[0] || null;
if (!primaryEntityId) return null;
const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null;
};
const patchSelectedGeometryMetadataLocally = (
feature: Feature,
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[]
) => {
editor.patchFeatureProperties(feature.properties.id, {
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
});
setGeometryMetaForm({
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
});
};
const patchSelectedFeatureEntitiesLocally = (
feature: Feature,
entityIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entityRows.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null;
const entityNames = entityIds
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
editor.patchFeatureProperties(feature.properties.id, {
type: nextGeometryType,
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
});
setSelectedGeometryEntityIds(entityIds);
};
const handleApplyGeometryMetadata = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
} finally {
setIsEntitySubmitting(false);
}
};
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
patchSelectedFeatureEntitiesLocally(selectedFeature, entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} 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 handleCreateEntityOnly = async () => {
const name = entityForm.name.trim();
if (!name) {
setEntityFormStatus("Tên entity là bắt buộc.");
return;
}
const slug = entityForm.slug.trim() || null;
const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID;
const normalizedName = name.toLowerCase();
const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName);
if (duplicatedName) {
setEntityFormStatus("Tên entity đã tồn tại.");
return;
}
if (slug) {
const normalizedSlug = slug.toLowerCase();
const duplicatedSlug = entities.some((entity) =>
(entity.slug || "").trim().toLowerCase() === normalizedSlug
);
if (duplicatedSlug) {
setEntityFormStatus("Slug entity đã tồn tại.");
return;
}
}
const entityId = buildClientEntityId();
const pendingCreate: PendingEntityCreate = {
id: entityId,
name,
slug,
type_id: typeId,
status: 1,
};
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
setPendingEntityCreates((prev) => [pendingCreate, ...prev]);
setCreatedEntities((prev) => {
if (prev.some((item) => item.id === pendingCreate.id)) return prev;
return [
{
id: pendingCreate.id,
name: pendingCreate.name,
type_id: pendingCreate.type_id || null,
},
...prev,
];
});
setEntityForm((prev) => ({
...prev,
name: "",
slug: "",
}));
setEntityStatus(null);
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit.");
if (selectedFeature) {
setEntitySearchQuery(pendingCreate.name);
setSelectedSearchEntityId(pendingCreate.id);
}
} finally {
setIsEntitySubmitting(false);
}
};
const handleOpenSelectedSection = async () => {
const sectionId = selectedSectionId.trim();
if (!sectionId) {
setEntityStatus("Hãy chọn section để mở.");
return;
}
if (pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
setIsOpeningSection(true);
setEntityStatus(null);
try {
await openSectionForEditing(sectionId);
setEntityStatus("Đã mở section để chỉnh sửa.");
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Mở section thất bại: ${err.body}`);
} else {
setEntityStatus("Mở section thất bại.");
}
} finally {
setIsOpeningSection(false);
}
};
const handleCreateAndOpenSection = async () => {
const title = newSectionTitle.trim();
if (!title) {
setEntityStatus("Tên section là bắt buộc.");
return;
}
if (pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
setIsOpeningSection(true);
setEntityStatus(null);
try {
const section = await createSection({
title,
user_id: editorUserId,
created_by: editorUserId,
});
const sections = await fetchSections();
setAvailableSections(sections);
setNewSectionTitle("");
await openSectionForEditing(section.id);
setEntityStatus("Đã tạo và mở section mới.");
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Tạo section thất bại: ${err.body}`);
} else {
setEntityStatus("Tạo section thất bại.");
}
} finally {
setIsOpeningSection(false);
}
};
const handleSubmitSection = async () => {
if (!activeSection || !sectionState?.head_commit_id) {
setEntityStatus("Chưa có commit để submit.");
return;
}
if (pendingSaveCount > 0) {
setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
return;
}
setIsSubmitting(true);
setEntityStatus(null);
try {
const submission = await submitSection(activeSection.id, {
submitted_by: editorUserId,
commit_id: sectionState.head_commit_id,
});
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Submit thất bại: ${err.body}`);
} else {
setEntityStatus("Submit thất bại.");
}
} finally {
setIsSubmitting(false);
}
};
const handleRestoreCommit = async (commitId: string) => {
if (!activeSection || !sectionState) {
setEntityStatus("Chưa mở được section editor.");
return;
}
if (pendingSaveCount > 0) {
setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
return;
}
setIsSaving(true);
setEntityStatus(null);
try {
const result = await restoreSectionCommit(activeSection.id, {
commit_id: commitId,
created_by: editorUserId,
expected_version: sectionState.version,
expected_head_commit_id: sectionState.head_commit_id,
});
const editorPayload = await openSectionEditor(activeSection.id, editorUserId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
setSectionState(result.state);
setLastSectionSnapshot(snapshot);
if (snapshot?.editor_feature_collection) {
setInitialData(snapshot.editor_feature_collection);
}
setSectionCommits(await fetchSectionCommits(activeSection.id));
setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Restore thất bại: ${err.body}`);
} else {
setEntityStatus("Restore thất bại.");
}
} finally {
setIsSaving(false);
}
};
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
const timelineStatusText =
pendingSaveCount > 0
? "Commit 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}
entityStatus={entityStatus}
onUndo={editor.undo}
onCommit={handleCommitSection}
onSubmit={handleSubmitSection}
onRestoreCommit={handleRestoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
isOpeningSection={isOpeningSection}
sectionTitle={activeSection?.title || "Đang tải section"}
sectionStatus={sectionState?.status || "editing"}
selectedSectionId={selectedSectionId}
editorUserId={editorUserIdInput}
sectionOptions={availableSections}
newSectionTitle={newSectionTitle}
commitTitle={commitTitle}
commitNote={commitNote}
onEditorUserIdChange={setEditorUserIdInput}
onSelectedSectionIdChange={setSelectedSectionId}
onNewSectionTitleChange={setNewSectionTitle}
onCommitTitleChange={setCommitTitle}
onCommitNoteChange={setCommitNote}
onOpenSection={handleOpenSelectedSection}
onCreateSection={handleCreateAndOpenSection}
commitCount={sectionCommits.length}
latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
createdEntities={createdEntities}
createdGeometries={createdGeometries}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? (
<Map
mode={mode}
draft={editor.draft}
selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId}
onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<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={
<SelectedGeometryPanel
selectedFeature={selectedFeature}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
: "Chưa gắn"
}
selectedFeatureBindingSummary={
selectedFeature
? formatBindingIdsForDisplay(selectedFeature)
: "Không có"
}
entities={entities}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onEntityIdsChange={handleEntityIdsChange}
entitySearchQuery={entitySearchQuery}
onEntitySearchQueryChange={setEntitySearchQuery}
entitySearchResults={entitySearchResults}
selectedSearchEntityId={selectedSearchEntityId}
onSelectSearchEntityId={setSelectedSearchEntityId}
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
isEntitySearchLoading={isEntitySearchLoading}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
entityTypeOptions={ENTITY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onApplyGeometryMetadata={handleApplyGeometryMetadata}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
}
/>
</div>
);
}
async function resolveDefaultSection(existingSections?: Section[], userId = DEFAULT_EDITOR_USER_ID): Promise<Section> {
const sections = existingSections || await fetchSections();
const existingDefault = sections.find((section) => section.title === DEFAULT_SECTION_TITLE);
if (existingDefault) {
return existingDefault;
}
return createSection({
title: DEFAULT_SECTION_TITLE,
user_id: userId,
created_by: userId,
description: "Default section used by the map editor UI.",
});
}
async function resolveOpenableSectionAfterConflict(
sections: Section[],
blockedSectionId: string,
userId: string
): Promise<Section> {
const now = Date.now();
const fallback = sections.find((section) =>
section.id !== blockedSectionId &&
section.state.status === "editing" &&
(
!section.state.locked_by ||
section.state.locked_by === userId ||
(
section.state.lock_expires_at !== null &&
Date.parse(section.state.lock_expires_at) <= now
)
)
);
if (fallback) return fallback;
return createSection({
title: `${DEFAULT_SECTION_TITLE} (${userId})`,
user_id: userId,
created_by: userId,
description: "Fallback section created because the default section is locked.",
});
}
function normalizeEditorUserId(value: string): string {
const normalized = value.trim();
return normalized || DEFAULT_EDITOR_USER_ID;
}
function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const snapshot = raw as EditorSnapshot;
if (
snapshot.editor_feature_collection &&
snapshot.editor_feature_collection.type === "FeatureCollection" &&
Array.isArray(snapshot.editor_feature_collection.features)
) {
return snapshot;
}
return {
...snapshot,
editor_feature_collection: undefined,
};
}
function buildEditorSnapshot(options: {
section: Section;
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id)));
const deletedIds = new Set(
options.changes
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
.map((change) => String(change.id))
);
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
const previousFeatures = new globalThis.Map<string, Feature>();
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
previousFeatures.set(String(feature.properties.id), feature);
if (!currentDraftIds.has(String(feature.properties.id))) {
deletedIds.add(String(feature.properties.id));
}
}
const previousGeometryOps = new globalThis.Map<string, string>();
for (const item of options.previousSnapshot?.geometries || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
const operation = typeof item.operation === "string" ? item.operation : "";
if (id && operation) previousGeometryOps.set(id, operation);
}
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, Record<string, unknown>>();
for (const item of options.previousSnapshot?.entities || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
if (id) entityRows.set(id, { ...item });
}
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
operation: "create",
name: entity.name,
slug: entity.slug,
description: null,
type_id: entity.type_id,
status: entity.status,
is_deleted: 0,
});
}
for (const feature of options.draft.features) {
for (const entityId of normalizeFeatureEntityIds(feature)) {
if (entityRows.has(entityId)) continue;
entityRows.set(entityId, {
id: entityId,
operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
slug: null,
description: null,
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1,
is_deleted: 0,
});
}
}
const geometries: Array<Record<string, unknown>> = options.draft.features.map((feature) => {
const id = String(feature.properties.id);
const previousOperation = previousGeometryOps.get(id);
const previousFeature = previousFeatures.get(id);
const changedFromPreviousSnapshot = previousFeature
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
: false;
const operation = previousOperation === "create"
? "create"
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: "reference";
const bbox = getFeatureBBox(feature);
return {
id,
operation,
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
time_end: feature.properties.time_end ?? null,
bbox: bbox
? {
min_lng: bbox.minLng,
min_lat: bbox.minLat,
max_lng: bbox.maxLng,
max_lat: bbox.maxLat,
}
: null,
is_deleted: 0,
};
});
for (const id of deletedIds) {
geometries.push({
id,
operation: "delete",
is_deleted: 1,
});
}
const linkScopes = options.draft.features
.map((feature) => ({
geometry_id: String(feature.properties.id),
operation: "replace",
entity_ids: normalizeFeatureEntityIds(feature),
}))
.filter((scope) => scope.entity_ids.length > 0);
return {
schema_version: 1,
section: {
id: options.section.id,
title: options.section.title,
},
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity;
return entity;
}),
geometries,
link_scopes: linkScopes,
};
}
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
const points = collectCoordinatePairs(feature.geometry.coordinates);
if (!points.length) return null;
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const [lng, lat] of points) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { minLng, minLat, maxLng, maxLat };
}
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
if (!Array.isArray(value)) return [];
if (
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number" &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
) {
return [[value[0], value[1]]];
}
return value.flatMap((item) => collectCoordinatePairs(item));
}
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 getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";
if (preset === "point") return "city";
if (preset === "circle-area") return "war";
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
const geometryType = feature.geometry.type;
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "defense_line";
}
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "city";
}
return DEFAULT_ENTITY_TYPE_ID;
}
function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] {
const defaultTypeId = getDefaultTypeIdForFeature(feature);
const defaultTypeOption = findEntityTypeOption(defaultTypeId);
if (defaultTypeOption) {
return [defaultTypeOption.groupId];
}
return ["polygon"];
}
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 normalizeFeatureBindingIds(feature: Feature): string[] {
const rawBinding = feature.properties.binding;
if (!Array.isArray(rawBinding)) return [];
return uniqueEntityIds(rawBinding
.map((id) => {
if (typeof id !== "string" && typeof id !== "number") return "";
return String(id).trim();
})
.filter((id) => id.length > 0));
}
function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
);
}
function formatBindingIdsForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature);
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
}
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 mergeEntitiesWithPending(
persistedEntities: Entity[],
pendingCreates: PendingEntityCreate[]
): Entity[] {
if (!pendingCreates.length) {
return persistedEntities;
}
const seen = new Set<string>();
const pendingAsEntities: Entity[] = [];
for (const pending of pendingCreates) {
if (seen.has(pending.id)) continue;
seen.add(pending.id);
pendingAsEntities.push({
id: pending.id,
name: pending.name,
slug: pending.slug,
type_id: pending.type_id,
status: pending.status,
geometry_count: 0,
created_at: undefined,
updated_at: undefined,
});
}
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
return [...pendingAsEntities, ...nextPersisted];
}
function mergeEntitySearchResults(
remoteRows: Entity[],
localRows: Entity[]
): Entity[] {
const merged: Entity[] = [];
const seen = new Set<string>();
for (const row of localRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
for (const row of remoteRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
return merged;
}
function buildClientEntityId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
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;
}