diff --git a/src/app/editor/[id]/editorPageUtils.ts b/src/app/editor/[id]/editorPageUtils.ts
index dcf6dc7..110e3c6 100644
--- a/src/app/editor/[id]/editorPageUtils.ts
+++ b/src/app/editor/[id]/editorPageUtils.ts
@@ -3,6 +3,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, Geometry } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
+import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
export function clampNumber(value: number, min: number, max: number): number {
@@ -18,10 +19,10 @@ export function formatCommitTitle(commit: ProjectCommit): string {
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
- const start = feature.properties.time_start;
- const end = feature.properties.time_end;
- if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
- if (typeof end === "number" && Number.isFinite(end) && year > end) return false;
+ const start = normalizeTimelineYearValue(feature.properties.time_start);
+ const end = normalizeTimelineYearValue(feature.properties.time_end);
+ if (start !== null && year < start) return false;
+ if (end !== null && year > end) return false;
return true;
}
@@ -57,8 +58,8 @@ export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | und
source: e.source,
name: typeof e.name === "string" ? e.name.trim() : "",
description: e.description == null ? null : String(e.description),
- time_start: typeof e.time_start === "number" ? e.time_start : null,
- time_end: typeof e.time_end === "number" ? e.time_end : null,
+ time_start: normalizeTimelineYearValue(e.time_start),
+ time_end: normalizeTimelineYearValue(e.time_end),
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx
index 1933a93..37e4497 100644
--- a/src/app/editor/[id]/page.tsx
+++ b/src/app/editor/[id]/page.tsx
@@ -52,7 +52,7 @@ import {
scaleImageOverlayCoordinatesByFactor,
type MapImageOverlay,
} from "@/uhm/components/map/imageOverlay";
-import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline";
+import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useFeatureCommands } from "./featureCommands";
import { deleteSubmission } from "@/uhm/api/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
@@ -126,7 +126,7 @@ function EditorPageContent() {
const {
mode,
internalSetMode,
- initialData,
+ baselineFeatureCollection,
isSaving,
isSubmitting,
isOpeningSection,
@@ -139,8 +139,8 @@ function EditorPageContent() {
baselineSnapshot,
entityCatalog,
setEntityCatalog,
- snapshotEntities,
- setSnapshotEntities,
+ snapshotEntityRows,
+ setSnapshotEntityRows,
entityStatus,
setEntityStatus,
selectedFeatureIds,
@@ -203,7 +203,7 @@ function EditorPageContent() {
} = useEditorStore(useShallow((state) => ({
mode: state.mode,
internalSetMode: state.setMode,
- initialData: state.initialData,
+ baselineFeatureCollection: state.baselineFeatureCollection,
isSaving: state.isSaving,
isSubmitting: state.isSubmitting,
isOpeningSection: state.isOpeningSection,
@@ -216,8 +216,8 @@ function EditorPageContent() {
baselineSnapshot: state.baselineSnapshot,
entityCatalog: state.entityCatalog,
setEntityCatalog: state.setEntityCatalog,
- snapshotEntities: state.snapshotEntities,
- setSnapshotEntities: state.setSnapshotEntities,
+ snapshotEntityRows: state.snapshotEntityRows,
+ setSnapshotEntityRows: state.setSnapshotEntityRows,
entityStatus: state.entityStatus,
setEntityStatus: state.setEntityStatus,
selectedFeatureIds: state.selectedFeatureIds,
@@ -284,12 +284,12 @@ function EditorPageContent() {
const geoSearchRequestRef = useRef(0);
// Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất.
- const snapshotEntitiesRef = useRef(snapshotEntities);
+ const snapshotEntityRowsRef = useRef(snapshotEntityRows);
const snapshotWikisRef = useRef(snapshotWikis);
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
useEffect(() => {
- snapshotEntitiesRef.current = snapshotEntities;
- }, [snapshotEntities]);
+ snapshotEntityRowsRef.current = snapshotEntityRows;
+ }, [snapshotEntityRows]);
useEffect(() => {
snapshotWikisRef.current = snapshotWikis;
}, [snapshotWikis]);
@@ -298,10 +298,10 @@ function EditorPageContent() {
}, [snapshotEntityWikiLinks]);
// Hook quản lý draft/changes/undo cho main editor và replay editor.
- const editor = useEditorState(initialData, {
+ const editor = useEditorState(baselineFeatureCollection, {
snapshotUndo: {
- snapshotEntitiesRef,
- setSnapshotEntities,
+ snapshotEntityRowsRef,
+ setSnapshotEntityRows,
snapshotWikisRef,
setSnapshotWikis,
snapshotEntityWikiLinksRef,
@@ -324,26 +324,39 @@ function EditorPageContent() {
},
[editor]
);
+ // Xóa wiki là một thay đổi snapshot kép: wiki row + các binding entity-wiki trỏ tới wiki đó.
+ const removeSnapshotWikiUndoable = useCallback(
+ (wikiId: string) => {
+ const id = String(wikiId || "").trim();
+ if (!id) return;
+ editor.setSnapshotWikisAndEntityWikiLinks(
+ (prev) => prev.filter((wiki) => wiki.id !== id),
+ (prev) => prev.filter((link) => String(link.wiki_id) !== id),
+ `Xóa wiki #${id}`
+ );
+ },
+ [editor]
+ );
// Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung.
- const snapshotEntitiesAsEntities = useMemo(() => {
- const rows = snapshotEntities || [];
+ const snapshotEntityRowsAsEntities = useMemo(() => {
+ const rows = snapshotEntityRows || [];
return rows
.filter((e) => e && e.operation !== "delete")
.map((e) => ({
id: String(e.id || ""),
name: String(e.name || "").trim() || String(e.id || ""),
description: e.description ?? null,
- time_start: e.time_start ?? null,
- time_end: e.time_end ?? null,
+ time_start: normalizeTimelineYearValue(e.time_start),
+ time_end: normalizeTimelineYearValue(e.time_end),
geometry_count: 0,
}))
.filter((e) => e.id.length > 0 && e.name.length > 0);
- }, [snapshotEntities]);
+ }, [snapshotEntityRows]);
// Entity list hợp nhất giữa backend catalog và snapshot local.
const entities = useMemo(
- () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
- [entityCatalog, snapshotEntitiesAsEntities]
+ () => mergeEntitySearchResults(entityCatalog, snapshotEntityRowsAsEntities),
+ [entityCatalog, snapshotEntityRowsAsEntities]
);
// State vị trí stage/step đang chọn trong replay editor.
const [replaySelection, setReplaySelection] = useState<{
@@ -450,7 +463,7 @@ function EditorPageContent() {
const localCreatedIds = localCreatedEntityIdsRef.current;
if (!localCreatedIds.size) return;
- const snapshotIds = new Set((snapshotEntities || []).map((entity) => String(entity.id || "")));
+ const snapshotIds = new Set((snapshotEntityRows || []).map((entity) => String(entity.id || "")));
setEntityCatalog((prev) => {
let changed = false;
const next = (prev || []).filter((entity) => {
@@ -465,7 +478,7 @@ function EditorPageContent() {
});
return changed ? next : prev;
});
- }, [snapshotEntities, setEntityCatalog]);
+ }, [snapshotEntityRows, setEntityCatalog]);
// Clamp năm timeline vào range cố định trước khi đưa vào store.
const handleTimelineYearChange = useCallback((nextYear: number) => {
@@ -484,33 +497,45 @@ function EditorPageContent() {
selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex,
onSelectStep: () => {},
});
+ const {
+ hiddenGeometryIds: replayPreviewHiddenGeometryIds,
+ timelineYear: replayPreviewTimelineYear,
+ timelineFilterEnabled: replayPreviewTimelineFilterEnabled,
+ resetPreview: resetReplayPreview,
+ playFromSelection: playReplayPreviewFromSelection,
+ playFromStart: playReplayPreviewFromStart,
+ activeCursor: replayPreviewActiveCursor,
+ activeWikiId: replayPreviewActiveWikiId,
+ sidebarOpen: replayPreviewSidebarOpen,
+ openWikiPanelById: openReplayPreviewWikiPanelById,
+ } = replayPreview;
// Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay.
const replayPreviewDraft = useMemo(() => {
const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION;
- if (!isReplayPreviewMode || replayPreview.hiddenGeometryIds.length === 0) {
+ if (!isReplayPreviewMode || replayPreviewHiddenGeometryIds.length === 0) {
return sourceDraft;
}
- const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
+ const hiddenIds = new Set(replayPreviewHiddenGeometryIds);
return {
...sourceDraft,
features: sourceDraft.features.filter(
(feature) => !hiddenIds.has(String(feature.properties.id))
),
};
- }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]);
+ }, [isReplayPreviewMode, previewSession?.draft, replayPreviewHiddenGeometryIds]);
const activeTimelineYear = isReplayPreviewMode
- ? replayPreview.timelineYear
+ ? replayPreviewTimelineYear
: timelineDraftYear;
const activeTimelineFilterEnabled = isReplayPreviewMode
- ? replayPreview.timelineFilterEnabled
+ ? replayPreviewTimelineFilterEnabled
: timelineFilterEnabled;
- // Timeline filter: only affects persisted snapshot features.
+ // Render draft is the only FeatureCollection that decides what appears on the map.
+ // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source.
// New features created in the current session remain visible regardless of time range.
- // Draft cuối cùng đưa vào map sau khi áp filter timeline.
- const timelineVisibleDraft = useMemo(() => {
+ const mapRenderDraft = useMemo(() => {
const activeDraft = isReplayPreviewMode
? replayPreviewDraft
: isReplayEditMode
@@ -555,8 +580,8 @@ function EditorPageContent() {
const selectedGeometryTime = useMemo(() => {
if (!selectedFeature) return null;
return {
- time_start: selectedFeature.properties.time_start ?? null,
- time_end: selectedFeature.properties.time_end ?? null,
+ time_start: normalizeTimelineYearValue(selectedFeature.properties.time_start),
+ time_end: normalizeTimelineYearValue(selectedFeature.properties.time_end),
};
}, [selectedFeature]);
@@ -566,8 +591,8 @@ function EditorPageContent() {
for (const [id, change] of editor.changes.entries()) {
if (change.action === "create") createdGeometryIds.add(String(id));
}
- const timelineVisibleGeometryIds = new Set(
- timelineVisibleDraft.features.map((feature) => String(feature.properties.id))
+ const mapRenderGeometryIds = new Set(
+ mapRenderDraft.features.map((feature) => String(feature.properties.id))
);
const rows = (editor.draft.features || [])
@@ -575,19 +600,38 @@ function EditorPageContent() {
.map((f) => {
const id = String(f.properties.id);
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
- const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type;
+ const label = semantic.length ? `${semantic} (${f.geometry.type})` : "Geometry";
+ const timeStart = normalizeTimelineYearValue(f.properties.time_start);
+ const timeEnd = normalizeTimelineYearValue(f.properties.time_end);
+ const hasStart = timeStart !== null;
+ const hasEnd = timeEnd !== null;
+ const timeStatus: "missing" | "partial" | "complete" =
+ !hasStart && !hasEnd
+ ? "missing"
+ : !hasStart || !hasEnd
+ ? "partial"
+ : "complete";
+ const isTimelineVisible = mapRenderGeometryIds.has(id);
+ const timelineStatus: "off" | "visible" | "filteredOut" = !activeTimelineFilterEnabled
+ ? "off"
+ : isTimelineVisible
+ ? "visible"
+ : "filteredOut";
return {
id,
label,
- time_start: f.properties.time_start ?? null,
- time_end: f.properties.time_end ?? null,
- isTimelineVisible: timelineVisibleGeometryIds.has(id),
+ time_start: timeStart,
+ time_end: timeEnd,
+ isTimelineVisible,
+ isOrphan: normalizeFeatureEntityIds(f).length === 0,
+ timeStatus,
+ timelineStatus,
isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id),
};
});
rows.sort((a, b) => a.id.localeCompare(b.id));
return rows;
- }, [editor, timelineVisibleDraft.features]);
+ }, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]);
// Binding ids của geometry đại diện đang chọn.
const selectedGeometryBindingIds = useMemo(() => {
@@ -620,13 +664,13 @@ function EditorPageContent() {
// Dirty flag cho entity snapshot so với baseline commit.
const entitiesDirty = useMemo(() => {
const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities);
- const next = normalizeEntitiesForCompare(snapshotEntities);
+ const next = normalizeEntitiesForCompare(snapshotEntityRows);
try {
return JSON.stringify(prev) !== JSON.stringify(next);
} catch {
return true;
}
- }, [baselineSnapshot?.entities, snapshotEntities]);
+ }, [baselineSnapshot?.entities, snapshotEntityRows]);
// Dirty flag cho binding entity-wiki so với baseline commit.
const entityWikiDirty = useMemo(() => {
@@ -679,11 +723,11 @@ function EditorPageContent() {
// Thoát preview và quay về replay edit mode.
const exitReplayPreview = useCallback(() => {
- replayPreview.resetPreview();
+ resetReplayPreview();
setPreviewAutoplayMode(null);
setPreviewSession(null);
internalSetMode("replay");
- }, [internalSetMode, replayPreview.resetPreview]);
+ }, [internalSetMode, resetReplayPreview]);
// Đóng băng draft/replay hiện tại thành session preview để phát thử.
const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => {
@@ -722,7 +766,7 @@ function EditorPageContent() {
}
if (mode === "replay_preview") {
- replayPreview.resetPreview();
+ resetReplayPreview();
setPreviewAutoplayMode(null);
setPreviewSession(null);
@@ -742,10 +786,12 @@ function EditorPageContent() {
if (m === "replay" && featureId) {
// QUY TẮC: Geo chọn đầu tiên là geo main.
+ const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId]));
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
+
setReplayFeatureId(triggerId);
setReplaySelection({ stageId: null, stepIndex: null });
- editor.switchReplayContext(triggerId, selectedFeatureIds);
+ editor.switchReplayContext(triggerId, finalSelectedIds);
setSelectedFeatureIds([]);
} else if (m !== "replay") {
if (mode === "replay") {
@@ -761,7 +807,7 @@ function EditorPageContent() {
editor,
internalSetMode,
mode,
- replayPreview.resetPreview,
+ resetReplayPreview,
selectedFeatureIds,
setHideOutside,
setReplayFeatureId,
@@ -809,17 +855,17 @@ function EditorPageContent() {
useEffect(() => {
if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return;
if (previewAutoplayMode === "selection") {
- replayPreview.playFromSelection();
+ playReplayPreviewFromSelection();
} else {
- replayPreview.playFromStart();
+ playReplayPreviewFromStart();
}
setPreviewAutoplayMode(null);
}, [
isReplayPreviewMode,
+ playReplayPreviewFromSelection,
+ playReplayPreviewFromStart,
previewAutoplayMode,
previewSession,
- replayPreview.playFromSelection,
- replayPreview.playFromStart,
]);
useEffect(() => {
@@ -831,29 +877,32 @@ function EditorPageContent() {
// Label ngắn cho overlay preview tại step đang phát.
const replayPreviewActiveStepLabel = useMemo(() => {
if (
- replayPreview.activeCursor.stageId == null ||
- replayPreview.activeCursor.stepIndex == null
+ replayPreviewActiveCursor.stageId == null ||
+ replayPreviewActiveCursor.stepIndex == null
) {
return null;
}
- return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
- }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
+ return `Stage #${replayPreviewActiveCursor.stageId} · Step ${replayPreviewActiveCursor.stepIndex + 1}`;
+ }, [replayPreviewActiveCursor.stageId, replayPreviewActiveCursor.stepIndex]);
- const replayPreviewWikiRows = previewSession?.wikis || [];
+ const replayPreviewWikiRows = useMemo(
+ () => previewSession?.wikis || [],
+ [previewSession?.wikis]
+ );
// Wiki snapshot đang được step preview yêu cầu mở.
const replayPreviewActiveWikiSnapshot = useMemo(() => {
- if (!replayPreview.activeWikiId) return null;
- return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null;
- }, [replayPreview.activeWikiId, replayPreviewWikiRows]);
+ if (!replayPreviewActiveWikiId) return null;
+ return replayPreviewWikiRows.find((item) => item.id === replayPreviewActiveWikiId) || null;
+ }, [replayPreviewActiveWikiId, replayPreviewWikiRows]);
useEffect(() => {
- if (!isReplayPreviewMode || !replayPreview.sidebarOpen) {
+ if (!isReplayPreviewMode || !replayPreviewSidebarOpen) {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
return;
}
- const activeWikiId = String(replayPreview.activeWikiId || "").trim();
+ const activeWikiId = String(replayPreviewActiveWikiId || "").trim();
if (!activeWikiId.length) {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
@@ -903,8 +952,8 @@ function EditorPageContent() {
}, [
isReplayPreviewMode,
previewWikiCache,
- replayPreview.activeWikiId,
- replayPreview.sidebarOpen,
+ replayPreviewActiveWikiId,
+ replayPreviewSidebarOpen,
replayPreviewWikiRows,
]);
@@ -934,8 +983,8 @@ function EditorPageContent() {
return;
}
setPreviewWikiError(null);
- replayPreview.openWikiPanelById(match.id);
- }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]);
+ openReplayPreviewWikiPanelById(match.id);
+ }, [openReplayPreviewWikiPanelById, replayPreviewWikiRows]);
// Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview.
const effectiveGeometryVisibility = useMemo(() => {
@@ -1257,12 +1306,12 @@ function EditorPageContent() {
useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
- timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id))
+ editor.draft.features.some(feature => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
- }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]);
+ }, [editor.draft.features, selectedFeatureIds, setSelectedFeatureIds]);
useEffect(() => {
if (!selectedFeature) {
@@ -1283,15 +1332,13 @@ function EditorPageContent() {
? selectedFeature.properties.type
: getDefaultTypeIdForFeature(selectedFeature);
const currentId = String(selectedFeature.properties.id);
+ const timeStart = normalizeTimelineYearValue(selectedFeature.properties.time_start);
+ const timeEnd = normalizeTimelineYearValue(selectedFeature.properties.time_end);
setSelectedGeometryEntityIds(featureEntityIds);
setGeometryMetaForm({
type_key: nextTypeKey,
- time_start: selectedFeature.properties.time_start != null
- ? String(selectedFeature.properties.time_start)
- : "",
- time_end: selectedFeature.properties.time_end != null
- ? String(selectedFeature.properties.time_end)
- : "",
+ time_start: timeStart != null ? String(timeStart) : "",
+ time_end: timeEnd != null ? String(timeEnd) : "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
});
// Only clear status when switching to a different geometry, not when patching metadata/bindings
@@ -1346,7 +1393,7 @@ function EditorPageContent() {
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
const id = String(entity.id || "").trim();
if (!id) return;
- editor.setSnapshotEntities((prev) => {
+ editor.setSnapshotEntityRows((prev) => {
if (prev.some((e) => String(e.id) === id)) return prev;
return [
{
@@ -1355,8 +1402,8 @@ function EditorPageContent() {
operation: "reference",
name: entity.name,
description: entity.description ?? null,
- time_start: entity.time_start ?? null,
- time_end: entity.time_end ?? null,
+ time_start: normalizeTimelineYearValue(entity.time_start),
+ time_end: normalizeTimelineYearValue(entity.time_end),
},
...prev,
];
@@ -1397,7 +1444,7 @@ function EditorPageContent() {
return;
}
- editor.setSnapshotEntities((prev) => prev.map((e) => {
+ editor.setSnapshotEntityRows((prev) => prev.map((e) => {
if (!e || String(e.id) !== id) return e;
const source = e.source === "inline" ? "inline" : "ref";
const operation =
@@ -1540,6 +1587,36 @@ function EditorPageContent() {
setIsEntitySubmitting,
]);
+ // Bind nhiều geometries vào target geometry.
+ const handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => {
+ const idStr = String(targetId).trim();
+ if (!idStr) return;
+
+ const targetFeature = editor.draft.features.find((f) => String(f.properties.id) === idStr);
+ if (!targetFeature) {
+ flashGeoBindingStatus("Không tìm thấy geometry đích.");
+ return;
+ }
+
+ const prevBindingIds = normalizeFeatureBindingIds(targetFeature);
+
+ // Merge prevBindingIds with sourceIds (which are strings of selected features)
+ // filter out targetId itself (we can't bind a geometry to itself)
+ const newSources = sourceIds.map(String).filter((x) => x !== idStr);
+ const merged = Array.from(new Set([...prevBindingIds, ...newSources]));
+
+ editor.patchFeaturePropertiesBatch(
+ [{
+ id: targetFeature.properties.id,
+ patch: { binding: merged },
+ }],
+ "Bind các geometry đã chọn vào GEO"
+ );
+
+ setSelectedFeatureIds([targetFeature.properties.id]);
+ flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. Commit khi sẵn sàng.`, 3000);
+ }, [editor, flashGeoBindingStatus, setSelectedFeatureIds]);
+
// Focus/zoom tới geometry từ binding panel; nếu geo có time_start thì kéo year filter về năm đó.
const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => {
const id = String(geoId || "").trim();
@@ -1551,8 +1628,8 @@ function EditorPageContent() {
return;
}
- const geoTimeStart = feature.properties.time_start;
- if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) {
+ const geoTimeStart = normalizeTimelineYearValue(feature.properties.time_start);
+ if (geoTimeStart !== null) {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart)));
}
@@ -1724,8 +1801,8 @@ function EditorPageContent() {
properties: {
id: geoId,
type: typeKey,
- time_start: typeof geo.time_start === "number" ? geo.time_start : null,
- time_end: typeof geo.time_end === "number" ? geo.time_end : null,
+ time_start: normalizeTimelineYearValue(geo.time_start),
+ time_end: normalizeTimelineYearValue(geo.time_end),
binding: bindingIds.length ? bindingIds : undefined,
entity_id: entityItem.entity_id,
entity_ids: [entityItem.entity_id],
@@ -1735,7 +1812,7 @@ function EditorPageContent() {
geometry,
};
- editor.createFeatureWithSnapshotEntities(
+ editor.createFeatureWithSnapshotEntityRows(
feature,
(prev) => {
if (prev.some((e) => String(e.id) === importedEntity.id)) return prev;
@@ -1827,7 +1904,7 @@ function EditorPageContent() {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
- editor.setSnapshotEntities((prev) => {
+ editor.setSnapshotEntityRows((prev) => {
if (prev.some((e) => String(e.id) === entityId)) return prev;
return [
{
@@ -1878,13 +1955,15 @@ function EditorPageContent() {
setSelectedFeatureIds([feature.properties.id]);
};
- // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng.
- const mapLabelSourceDraft = isReplayPreviewMode
+ // Base draft for label lookup only. It must not decide which geometry is rendered.
+ const labelContextBaseDraft = isReplayPreviewMode
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
: editor.draft;
+ // Enriched label context may contain geometries that mapRenderDraft filtered out.
+ // Map rendering must still use mapRenderDraft above.
const mapLabelContextDraft = useMemo(
- () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities),
- [entities, mapLabelSourceDraft]
+ () => buildEntityLabelContextDraft(labelContextBaseDraft, entities),
+ [entities, labelContextBaseDraft]
);
return (
@@ -2001,24 +2080,31 @@ function EditorPageContent() {
ref={mapHandleRef}
mode={mode}
onSetMode={setMode}
- draft={timelineVisibleDraft}
+ renderDraft={mapRenderDraft}
labelContextDraft={mapLabelContextDraft}
labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
onCreateFeature={handleCreateFeature}
- onDeleteFeature={editor.deleteFeature}
+ onDeleteFeature={(id) => {
+ if (Array.isArray(id)) {
+ editor.deleteFeatures(id);
+ } else {
+ editor.deleteFeature(id);
+ }
+ }}
onHideFeature={handleHideGeometryLocal}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
geometryVisibility={effectiveGeometryVisibility}
- respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled}
+ applyGeometryBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled}
highlightFeatures={null}
focusFeatureCollection={geometryFocusRequest?.collection || null}
focusRequestKey={geometryFocusRequest?.key ?? null}
focusPadding={96}
imageOverlay={imageOverlay}
onImageOverlayChange={setImageOverlay}
+ onBindGeometries={handleBindGeometries}
/>
) : (
@@ -2154,15 +2240,21 @@ function EditorPageContent() {
- {selectedFeature ? (
+ {selectedFeatures.length > 0 ? (
{
+ editor.deleteFeatures(ids);
+ setSelectedFeatureIds([]);
+ }}
+ onDeselectAll={() => setSelectedFeatureIds([])}
changeCount={editor.changeCount}
onReplayEdit={(id) => setMode("replay", id)}
/>
@@ -2243,8 +2335,8 @@ function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity
return {
id,
name,
- time_start: entity?.time_start ?? null,
- time_end: entity?.time_end ?? null,
+ time_start: normalizeTimelineYearValue(entity?.time_start),
+ time_end: normalizeTimelineYearValue(entity?.time_end),
};
}).filter((candidate) => candidate !== null);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7a01287..76b3808 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -47,6 +47,8 @@ type LinkEntityPopupState = {
left: number;
};
+type CachedWiki = Wiki & { __fetched?: boolean };
+
const EMPTY_RELATIONS: RelationIndex = {
entitiesById: {},
entityGeometriesById: {},
@@ -84,7 +86,7 @@ export default function Page() {
const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState(null);
const [activeWikiSlug, setActiveWikiSlug] = useState(null);
- const [wikiCache, setWikiCache] = useState>({});
+ const [wikiCache, setWikiCache] = useState>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState(null);
const [linkEntityPopup, setLinkEntityPopup] = useState(null);
@@ -123,13 +125,6 @@ export default function Page() {
const hoverPopupHoveredRef = useRef(false);
const linkEntityPopupRef = useRef(null);
- const selectedFeature = useMemo(() => {
- if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
- return (
- data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
- );
- }, [data.features, selectedFeatureIds]);
-
useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
@@ -416,7 +411,7 @@ export default function Page() {
};
}, [linkEntityPopup]);
- const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined;
+ const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
useEffect(() => {
if (!activeWikiSlug) {
@@ -459,7 +454,7 @@ export default function Page() {
if (disposed) return;
setWikiCache((prev) => ({
...prev,
- [activeWikiSlug]: { ...row, content: versionContent, __fetched: true } as any,
+ [activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
}));
} else {
setWikiCache((prev) => ({
@@ -525,7 +520,7 @@ export default function Page() {
{isBackgroundVisibilityReady ? (