1618 lines
59 KiB
TypeScript
1618 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("Section hiện tại chưa có head để 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,
|
|
});
|
|
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 headCommit = sectionState?.head_commit_id
|
|
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
|
: null;
|
|
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}
|
|
hasHeadCommit={Boolean(sectionState?.head_commit_id)}
|
|
headCommitId={sectionState?.head_commit_id || null}
|
|
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : 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 formatCommitTitle(commit: SectionCommit): string {
|
|
return commit.title?.trim() || `Commit #${commit.commit_no}`;
|
|
}
|
|
|
|
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;
|
|
}
|