Compare commits

..

6 Commits

47 changed files with 2592 additions and 815 deletions
+7 -6
View File
@@ -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));
}
+184 -92
View File
@@ -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}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
@@ -2154,15 +2240,21 @@ function EditorPageContent() {
<WikiSidebarPanel
projectId={projectId}
setWikis={setSnapshotWikisUndoable}
onRemoveWiki={removeSnapshotWikiUndoable}
/>
<EntityWikiBindingsPanel
setLinks={setSnapshotEntityWikiLinksUndoable}
/>
{selectedFeature ? (
{selectedFeatures.length > 0 ? (
<SelectedGeometryPanel
selectedFeatures={selectedFeatures}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
onDeleteFeatures={(ids) => {
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);
+7 -12
View File
@@ -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<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
@@ -123,13 +125,6 @@ export default function Page() {
const hoverPopupHoveredRef = useRef(false);
const linkEntityPopupRef = useRef<HTMLDivElement | null>(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 ? (
<Map
mode="select"
draft={data}
renderDraft={data}
labelContextDraft={mapLabelContextDraft}
labelTimelineYear={timelineDraftYear}
selectedFeatureIds={selectedFeatureIds}
@@ -533,7 +528,7 @@ export default function Page() {
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
respectBindingFilter={true}
applyGeometryBindingFilter={true}
onHoverFeatureChange={handleMapHoverChange}
highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries}
+34 -18
View File
@@ -3,7 +3,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import ComponentCard from "@/components/common/ComponentCard";
import { toast } from "sonner";
import { useModal } from "@/hooks/useModal";
@@ -11,19 +10,39 @@ import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button";
import Label from "@/components/form/Label";
import Badge from "@/components/ui/badge/Badge";
import { CreateProjectPayload, Project } from "@/interface/project";
import { CreateProjectPayload, Project, ProjectMember } from "@/interface/project";
import {
apiCreateProject,
apiCreateProjectCommit,
apiGetProjectCommits,
getCurrentProject,
} from "@/service/projectService";
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { EditorSnapshot } from "@/uhm/types/projects";
import { normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { EditorSnapshot, ProjectCommit } from "@/uhm/types/projects";
import StickyHeader from "@/components/ui/StickyHeader";
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function extractProjectCommitList(value: unknown): ProjectCommit[] {
let rows: unknown[] = [];
if (Array.isArray(value)) {
rows = value;
} else if (isRecord(value)) {
if (Array.isArray(value.items)) {
rows = value.items;
} else if (Array.isArray(value.data)) {
rows = value.data;
} else if (isRecord(value.data) && Array.isArray(value.data.items)) {
rows = value.data.items;
}
}
return rows.filter((row): row is ProjectCommit => isRecord(row) && typeof row.id === "string");
}
export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
@@ -101,10 +120,11 @@ export default function ProjectsPage() {
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
if (importSnapshot) {
const snapshot = toApiEditorSnapshot(importSnapshot);
await apiCreateProjectCommit(projectId, {
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
snapshot_json: importSnapshot as any,
} as any);
snapshot_json: snapshot,
});
toast.success("Tạo dự án từ JSON thành công!");
} else {
toast.success("Tạo dự án mới thành công!");
@@ -138,11 +158,10 @@ export default function ProjectsPage() {
}
setIsExportingProjectId(projectId);
try {
const res: any = await apiGetProjectCommits(projectId);
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
const commits = Array.isArray(rawList) ? rawList : [];
const res = await apiGetProjectCommits(projectId);
const commits = extractProjectCommitList(res);
const head =
commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
commits.find((c) => String(c.id || "") === headCommitId) || null;
const snapshot = head?.snapshot_json ?? null;
if (!snapshot) {
toast.error("Không tìm thấy snapshot_json của head commit.");
@@ -200,12 +219,9 @@ export default function ProjectsPage() {
}
};
const sortedProjects = [...projects].sort((a: any, b: any) => {
let valA = a[sortBy];
let valB = b[sortBy];
if (!valA) valA = "";
if (!valB) valB = "";
const sortedProjects = [...projects].sort((a, b) => {
const valA = String(a[sortBy] || "");
const valB = String(b[sortBy] || "");
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
@@ -331,7 +347,7 @@ export default function ProjectsPage() {
</div>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{sortedProjects.map((project: any) => (
{sortedProjects.map((project) => (
<div
key={project.id}
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
@@ -383,7 +399,7 @@ export default function ProjectsPage() {
<>
{project.members
.slice(0, 4)
.map((m: any, index: number) =>
.map((m: ProjectMember, index: number) =>
m.avatar_url ? (
<Image
key={index}
+5 -3
View File
@@ -1,3 +1,5 @@
import type { EditorSnapshot } from "@/uhm/types/projects";
export interface Project {
id: string;
title: string;
@@ -14,9 +16,9 @@ export interface Project {
display_name: string;
avatar_url: string;
};
commits?: any[];
commits?: unknown[];
// Legacy (old BE): submission_ids
submission_ids?: any[];
submission_ids?: string[];
// New BE: lightweight submissions list on project response
submissions?: Array<{ id: string; status: string }>;
members?: ProjectMember[];
@@ -63,7 +65,7 @@ export interface GetProjectsParams {
}
export interface CreateCommitPayload {
edit_summary: string;
snapshot_json: number[];
snapshot_json: EditorSnapshot;
}
export interface RestoreCommitPayload {
commit_id: string;
+10
View File
@@ -214,11 +214,21 @@
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
}
.toggleContainer:focus-visible {
outline: 2px solid rgba(52, 211, 153, 0.8);
outline-offset: 3px;
border-radius: 999px;
}
.toggleTrack {
width: 38px;
height: 20px;
+22 -13
View File
@@ -33,20 +33,23 @@ export type MapHandle = {
type MapProps = {
mode: EditorMode;
draft: FeatureCollection;
// FeatureCollection that should actually be rendered/interacted with on the map.
// Callers should apply timeline/replay filters before passing it here.
renderDraft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
// Label lookup context only. It may include non-rendered geometries for entity label resolution.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
applyGeometryBindingFilter?: boolean;
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
@@ -57,12 +60,13 @@ type MapProps = {
focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
};
const Map = forwardRef<MapHandle, MapProps>(function Map({
mode,
onSetMode,
draft,
renderDraft,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
@@ -74,7 +78,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onHideFeature,
onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
applyGeometryBindingFilter = true,
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
@@ -85,11 +89,12 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusPadding,
imageOverlay = null,
onImageOverlayChange,
onBindGeometries,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
const draftRef = useRef<FeatureCollection>(draft);
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
// Ref callback select feature mới nhất cho event click trên map.
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
@@ -108,9 +113,11 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
// Ref callback bind geometry mới nhất để interaction không stale.
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
@@ -120,6 +127,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
@@ -154,7 +162,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
mapRef,
mode,
modeRef,
draftRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
@@ -164,23 +172,24 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onHideRef,
onUpdateRef,
onHoverFeatureChangeRef,
onBindGeometriesRef,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyDraftToMap,
applyRenderDraftToMap,
applyHighlightToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
mapRef,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
@@ -200,7 +209,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
applyImageOverlayToMap();
setupMapInteractions(map);
applyDraftToMap(draftRef.current);
applyRenderDraftToMap(renderDraftRef.current);
tryCenterToUserLocation();
return () => {
@@ -31,13 +31,13 @@ function wikiTitle(w: WikiSnapshot): string {
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
const {
entityCatalog,
snapshotEntities,
snapshotEntityRows,
wikis,
links,
} = useEditorStore(
useShallow((state) => ({
entityCatalog: state.entityCatalog,
snapshotEntities: state.snapshotEntities,
snapshotEntityRows: state.snapshotEntityRows,
wikis: state.snapshotWikis,
links: state.snapshotEntityWikiLinks,
}))
@@ -59,18 +59,18 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
);
const entityChoices = useMemo<EntityChoice[]>(() => {
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntities || []) {
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
visibleSnapshotEntities.set(id, {
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
visibleSnapshotEntityRows.set(id, {
id,
name: String(ref?.name || id),
isNew: ref?.source === "inline" && ref?.operation === "create",
});
}
const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => {
const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => {
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
return {
id: entity.id,
@@ -80,7 +80,7 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
});
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [entityCatalog, snapshotEntities]);
}, [entityCatalog, snapshotEntityRows]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
@@ -3,17 +3,29 @@
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: number | null;
time_end?: number | null;
time_start?: unknown;
time_end?: unknown;
isTimelineVisible?: boolean;
isOrphan?: boolean;
timeStatus?: GeometryTimeStatus;
timelineStatus?: GeometryTimelineStatus;
isNew?: boolean;
};
type GeometryTimeStatus = "missing" | "partial" | "complete";
type GeometryTimelineStatus = "off" | "visible" | "filteredOut";
type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "timeStatus" | "timelineStatus" | "isNew">> & {
time_start: number | null;
time_end: number | null;
isTimelineVisible: boolean;
};
type Props = {
geometries: GeometryChoice[];
selectedGeometryId?: string | null;
@@ -61,9 +73,12 @@ export default function GeometryBindingPanel({
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: typeof g.time_start === "number" ? g.time_start : null,
time_end: typeof g.time_end === "number" ? g.time_end : null,
time_start: normalizeTimelineYearValue(g.time_start),
time_end: normalizeTimelineYearValue(g.time_end),
isTimelineVisible: Boolean(g.isTimelineVisible),
isOrphan: Boolean(g.isOrphan),
timeStatus: resolveTimeStatus(g),
timelineStatus: resolveTimelineStatus(g),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
@@ -85,6 +100,31 @@ export default function GeometryBindingPanel({
return a.id.localeCompare(b.id);
});
}, [bindingSet, effectiveSelectedGeometryId, rows]);
const summary = useMemo(() => {
let orphan = 0;
let missingTime = 0;
let partialTime = 0;
let filteredOut = 0;
let hidden = 0;
for (const row of rows) {
if (row.isOrphan) orphan += 1;
if (row.timeStatus === "missing") missingTime += 1;
if (row.timeStatus === "partial") partialTime += 1;
if (row.timelineStatus === "filteredOut") filteredOut += 1;
if (geometryVisibility[row.id] === false) hidden += 1;
}
return {
total: rows.length,
orphan,
missingTime,
partialTime,
timeIssues: missingTime + partialTime,
filteredOut,
hidden,
};
}, [geometryVisibility, rows]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return;
@@ -114,29 +154,72 @@ export default function GeometryBindingPanel({
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
<label
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
userSelect: "none",
}}
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
>
<input
type="checkbox"
checked={bindingFilterEnabled}
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
style={{ width: 14, height: 14 }}
<button
type="button"
role="switch"
aria-checked={bindingFilterEnabled}
aria-label="Toggle geometry binding filter"
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
style={{
width: 32,
height: 18,
padding: 2,
borderRadius: 999,
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
transition: "background 140ms ease, border-color 140ms ease",
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: 999,
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
transition: "background 140ms ease, box-shadow 140ms ease",
}}
/>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
</label>
</button>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={summaryWrapStyle}>
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
{summary.orphan > 0 ? (
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
) : null}
{summary.timeIssues > 0 ? (
<span
style={summaryWarningBadgeStyle}
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
>
time {summary.timeIssues}
</span>
) : null}
{summary.filteredOut > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
) : null}
{summary.hidden > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
@@ -164,8 +247,8 @@ export default function GeometryBindingPanel({
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const idColor = getGeometryIdColor(selectedGeometry);
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
const isBound = bindingSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return (
<div
style={{
@@ -179,7 +262,7 @@ export default function GeometryBindingPanel({
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={selectedGeometry.id}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
@@ -198,19 +281,7 @@ export default function GeometryBindingPanel({
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
fontSize: "12px",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.label || selectedGeometry.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
@@ -225,18 +296,7 @@ export default function GeometryBindingPanel({
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<div
style={{
marginTop: 3,
fontSize: "11px",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.id}
</div>
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
</div>
);
})()
@@ -248,8 +308,7 @@ export default function GeometryBindingPanel({
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false;
const idColor = getGeometryIdColor(g);
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
const title = buildGeometryTitle(g, isHidden, isBound);
return (
<div
key={g.id}
@@ -269,7 +328,7 @@ export default function GeometryBindingPanel({
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={g.id}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
@@ -284,33 +343,10 @@ export default function GeometryBindingPanel({
minWidth: 0,
}}
>
<span
style={{
fontSize: "12px",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{g.label || g.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
<GeometryLabel row={g} />
{g.isNew ? <NewBadge /> : null}
</div>
<div
style={{
fontSize: "11px",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{g.id}
</div>
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
</div>
<button
@@ -375,7 +411,86 @@ export default function GeometryBindingPanel({
);
}
const boundBadgeStyle: CSSProperties = {
function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) {
return (
<span
style={{
fontSize: "12px",
color,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.label || "Geometry"}
</span>
);
}
function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) {
return (
<div style={statusChipRowStyle}>
{row.isOrphan ? <span style={dangerBadgeStyle}>no entity</span> : null}
{row.timeStatus === "missing" ? <span style={dangerBadgeStyle}>no time</span> : null}
{row.timeStatus === "partial" ? <span style={warningBadgeStyle}>partial time</span> : null}
{row.timelineStatus === "visible" ? <span style={timelineBadgeStyle}>timeline</span> : null}
{row.timelineStatus === "filteredOut" ? <span style={mutedBadgeStyle}>out timeline</span> : null}
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
</div>
);
}
function resolveTimeStatus(geometry: GeometryChoice): GeometryTimeStatus {
if (geometry.timeStatus === "missing" || geometry.timeStatus === "partial" || geometry.timeStatus === "complete") {
return geometry.timeStatus;
}
const hasStart = normalizeTimelineYearValue(geometry.time_start) !== null;
const hasEnd = normalizeTimelineYearValue(geometry.time_end) !== null;
if (!hasStart && !hasEnd) return "missing";
if (!hasStart || !hasEnd) return "partial";
return "complete";
}
function resolveTimelineStatus(geometry: GeometryChoice): GeometryTimelineStatus {
if (
geometry.timelineStatus === "off" ||
geometry.timelineStatus === "visible" ||
geometry.timelineStatus === "filteredOut"
) {
return geometry.timelineStatus;
}
return geometry.isTimelineVisible ? "visible" : "off";
}
function buildGeometryTitle(row: GeometryRow, isHidden: boolean, isBound: boolean): string {
const parts = [`ID: ${row.id}`];
if (row.isOrphan) parts.push("Orphan");
if (row.timeStatus === "missing") parts.push("Missing time");
if (row.timeStatus === "partial") parts.push("Partial time");
if (row.timelineStatus === "visible") parts.push("Timeline visible");
if (row.timelineStatus === "filteredOut") parts.push("Filtered out by timeline");
if (isHidden) parts.push("Hidden");
if (isBound) parts.push("Bound");
if (row.isNew) parts.push("New");
return parts.join(" | ");
}
const summaryWrapStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
minWidth: 0,
flexWrap: "wrap",
};
const baseBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
@@ -383,32 +498,91 @@ const boundBadgeStyle: CSSProperties = {
height: 17,
padding: "0 6px",
borderRadius: 999,
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
whiteSpace: "nowrap",
};
const summaryBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.35)",
background: "rgba(15, 23, 42, 0.9)",
color: "#cbd5e1",
};
const summaryDangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.32)",
color: "#fecaca",
};
const summaryWarningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.48)",
background: "rgba(113, 63, 18, 0.3)",
color: "#fde68a",
};
const summaryMutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.4)",
background: "rgba(51, 65, 85, 0.32)",
color: "#cbd5e1",
};
const statusChipRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
marginTop: 5,
minHeight: 17,
};
const dangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.28)",
color: "#fecaca",
};
const warningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.5)",
background: "rgba(113, 63, 18, 0.28)",
color: "#fde68a",
};
const timelineBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(34, 197, 94, 0.5)",
background: "rgba(20, 83, 45, 0.3)",
color: "#bbf7d0",
};
const mutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.28)",
color: "#cbd5e1",
};
const boundBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const hiddenBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const iconButtonStyle: CSSProperties = {
@@ -424,14 +598,6 @@ const iconButtonStyle: CSSProperties = {
flex: "0 0 auto",
};
function getGeometryIdColor(geometry: GeometryChoice): string {
const hasStart = typeof geometry.time_start === "number";
const hasEnd = typeof geometry.time_end === "number";
if (!hasStart && !hasEnd) return "#f87171";
if (!hasStart || !hasEnd) return "#facc15";
return "#94a3b8";
}
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -22,7 +22,7 @@ export default function ProjectEntityRefsPanel({
onToggleBindEntityForSelectedGeometry,
}: Props) {
const {
snapshotEntities,
snapshotEntityRows,
entityForm,
setEntityForm,
isEntitySubmitting,
@@ -30,7 +30,7 @@ export default function ProjectEntityRefsPanel({
selectedGeometryEntityIds,
} = useEditorStore(
useShallow((state) => ({
snapshotEntities: state.snapshotEntities,
snapshotEntityRows: state.snapshotEntityRows,
entityForm: state.entityForm,
setEntityForm: state.setEntityForm,
isEntitySubmitting: state.isEntitySubmitting,
@@ -53,14 +53,14 @@ export default function ProjectEntityRefsPanel({
);
const entityRefs = useMemo(() => {
const byId = new globalThis.Map<string, EntitySnapshot>();
for (const ref of snapshotEntities || []) {
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || byId.has(id)) continue;
if (ref.operation === "delete") continue;
byId.set(id, ref);
}
return Array.from(byId.values());
}, [snapshotEntities]);
}, [snapshotEntityRows]);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
@@ -18,6 +18,8 @@ type Props = {
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
onReplayEdit?: (id: string | number) => void;
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
};
export default function SelectedGeometryPanel({
@@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({
onApplyGeometryMetadata,
changeCount,
onReplayEdit,
onDeleteFeatures,
onDeselectAll,
}: Props) {
const {
geometryMetaForm,
@@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
const isBulkMode = selectedFeatures.length >= 2;
const isMultiEditValid = useMemo(() => {
if (selectedFeatures.length <= 1) return true;
const firstShape = selectedFeatures[0].geometry.type;
return selectedFeatures.every((f) => f.geometry.type === firstShape);
}, [selectedFeatures]);
if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0];
@@ -99,7 +110,7 @@ export default function SelectedGeometryPanel({
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>
Geometry property
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
</div>
<button
type="button"
@@ -126,6 +137,77 @@ export default function SelectedGeometryPanel({
{collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
{isBulkMode && (
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #334155",
borderRadius: "8px",
padding: "8px",
background: "#1e293b",
}}
>
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
HÀNH ĐNG NHANH
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeselectAll?.()}
style={{
border: "1px solid #475569",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "transparent",
color: "#cbd5e1",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Bỏ chọn tất cả
</button>
</div>
</div>
)}
<div
style={{
display: "grid",
@@ -142,6 +224,13 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
</div>
{!isMultiEditValid ? (
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
</div>
) : (
<>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
@@ -207,22 +296,15 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
{/*<input*/}
{/* value={geometryMetaForm.binding}*/}
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
{/* placeholder="binding (geometry ids, comma separated)"*/}
{/* disabled={isEntitySubmitting}*/}
{/* style={entityInputStyle}*/}
{/*/>*/}
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
Apply
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
{onReplayEdit && selectedFeatures.length > 0 && (
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
@@ -247,6 +329,8 @@ export default function SelectedGeometryPanel({
{visibleGeoApplyFeedback.text}
</div>
) : null}
</>
)}
</div>
{changeCount > 0 ? (
@@ -49,6 +49,7 @@ export function formatUndoLabel(action: UndoAction) {
case "snapshot_wikis":
case "snapshot_entity_wiki":
case "replay":
case "replays":
case "replay_session":
case "group":
return action.label;
+21 -8
View File
@@ -16,29 +16,33 @@ type EngineBinding = {
cleanup: () => void;
cancel?: () => void;
clearSelection?: (skipNotify?: boolean) => void;
syncSelection?: (ids: (string | number)[]) => void;
};
type UseMapInteractionProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
mode: EditorMode;
modeRef: React.MutableRefObject<EditorMode>;
draftRef: React.MutableRefObject<FeatureCollection>;
// Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by
// replay/timeline state, so do not treat it as the canonical commit/edit draft.
renderDraftRef: React.MutableRefObject<FeatureCollection>;
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
};
export function useMapInteraction({
mapRef,
mode,
modeRef,
draftRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
@@ -48,6 +52,7 @@ export function useMapInteraction({
onHideRef,
onUpdateRef,
onHoverFeatureChangeRef,
onBindGeometriesRef,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
@@ -72,6 +77,13 @@ export function useMapInteraction({
}
}, [mode, selectedFeatureIds]);
useEffect(() => {
const selectEngine = engineBindingsRef.current.select;
if (selectEngine?.syncSelection) {
selectEngine.syncSelection(selectedFeatureIds);
}
}, [selectedFeatureIds]);
useEffect(() => {
const previousMode = previousModeRef.current;
if (previousMode !== mode) {
@@ -134,7 +146,7 @@ export function useMapInteraction({
map,
() => modeRef.current,
allowGeometryEditing
? (id: string | number) => {
? (id: string | number | (string | number)[]) => {
editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id);
@@ -143,7 +155,7 @@ export function useMapInteraction({
allowGeometryEditing
? (feature) => {
const rawId = feature.id ?? feature.properties?.id;
const originalFeature = draftRef.current.features.find(
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
editingEngineRef.current?.beginEditing(
@@ -153,7 +165,7 @@ export function useMapInteraction({
: undefined,
allowGeometryEditing
? (id: string | number) => {
const originalFeature = draftRef.current.features.find(
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
@@ -170,7 +182,8 @@ export function useMapInteraction({
: undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current)
() => Boolean(editingEngineRef.current?.editingRef.current),
(targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds)
);
const cleanupPoint = initPoint(
@@ -301,7 +314,7 @@ export function useMapInteraction({
}
const currentFeature =
draftRef.current.features.find(
renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawFeatureId)
) || null;
+28 -24
View File
@@ -20,13 +20,17 @@ import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
draft: FeatureCollection;
// Already-filtered FeatureCollection that should be written to MapLibre sources.
// Timeline/replay filters must be applied before this hook receives it.
renderDraft: FeatureCollection;
// Lookup-only context for labels. It may contain geometries that are not rendered.
// Never use it to decide which geometries appear on the map.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
respectBindingFilter: boolean;
applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
@@ -44,13 +48,13 @@ type UseMapSyncProps = {
export function useMapSync({
mapRef,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
@@ -62,13 +66,13 @@ export function useMapSync({
editingEngineRef,
geolocationCenteredRef,
}: UseMapSyncProps) {
const draftRef = useRef<FeatureCollection>(draft);
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const respectBindingFilterRef = useRef(respectBindingFilter);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
@@ -77,13 +81,13 @@ export function useMapSync({
const fitBoundsAppliedRef = useRef(false);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
@@ -94,8 +98,8 @@ export function useMapSync({
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyDraftToMap = useCallback((
fc: FeatureCollection,
const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[],
highlightFeaturesOverride?: FeatureCollection | null
@@ -115,22 +119,22 @@ export function useMapSync({
}
}
const labelContext = labelContextOverride || labelContextDraftRef.current || fc;
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
? highlightFeaturesOverride
: highlightFeaturesRef.current;
const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(labelContext, currentSelectedIds, highlightFeaturesVal)
: labelContext;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
: renderFc;
const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(visibleDraft);
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
countriesSource.setData(labeledGeometries);
placesSource.setData(labeledPoints);
@@ -147,7 +151,7 @@ export function useMapSync({
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft);
}
}, [mapRef]);
@@ -206,24 +210,24 @@ export function useMapSync({
}, [imageOverlay, mapRef]);
useEffect(() => {
applyDraftToMap(draft, labelContextDraft, selectedFeatureIds, highlightFeatures);
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
const stillExists = renderDraft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
geometryVisibility,
highlightFeatures,
applyDraftToMap,
applyRenderDraftToMap,
editingEngineRef,
]);
@@ -259,7 +263,7 @@ export function useMapSync({
}, [focusRequestKey, mapRef]);
return {
applyDraftToMap,
applyRenderDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
+8 -11
View File
@@ -55,9 +55,15 @@ export default function TimelineBar({
>
<div className={styles.flexWrapper}>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<label
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
@@ -67,15 +73,7 @@ export default function TimelineBar({
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
<input
type="checkbox"
checked={filterEnabled}
onChange={(e) => onFilterEnabledChange(e.target.checked)}
disabled={effectiveDisabled}
aria-label="Toggle timeline filter"
style={{ display: "none" }}
/>
</label>
</button>
) : null}
<span className={styles.labelBounds}>{formatYear(lower)}</span>
<input
@@ -133,4 +131,3 @@ function formatYear(year: number): string {
}
return `${year}`;
}
+6 -1
View File
@@ -56,6 +56,7 @@ let quillLinkSanitizePatched = false;
type Props = {
projectId: string;
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
onRemoveWiki?: (wikiId: string) => void;
};
function clampTitle(title: string) {
@@ -63,7 +64,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -252,7 +253,11 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
};
const removeWiki = (id: string) => {
if (onRemoveWiki) {
onRemoveWiki(id);
} else {
setWikis((prev) => prev.filter((w) => w.id !== id));
}
if (activeId === id) setActiveId(null);
};
+12 -7
View File
@@ -11,7 +11,8 @@
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ `geometries[].type`.
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ
* `time_start/time_end` và `geometries[].type`.
*/
// ---- Root request ----
@@ -53,6 +54,12 @@ export type FeatureProperties = {
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_label_candidates?: Array<{
id: string;
name: string;
time_start?: number | null;
time_end?: number | null;
}>;
entity_type_id?: string | null;
point_label?: string | null;
line_label?: string | null;
@@ -85,6 +92,8 @@ export type EntitySnapshot = {
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
time_start?: number | null;
time_end?: number | null;
};
export type GeometrySnapshot = {
@@ -267,15 +276,11 @@ export type ReplayGeoFunctionParamTupleDocs = {
padding?: number,
duration?: number,
];
fly_to_geometries: [geometry_ids: string[]];
fly_to_geometries: [geometry_ids: string[], duration?: number];
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
show_geometries: [geometry_ids: string[]];
hide_geometries: [geometry_ids: string[]];
fit_to_geometries: [
geometry_ids: string[],
padding?: number,
duration?: number,
];
fit_to_geometries: [geometry_ids: string[], duration?: number];
orbit_camera_around_geometry: [
geometry_id: string,
zoom?: number,
+17 -5
View File
@@ -17,6 +17,14 @@ Tài liệu này dành cho người sửa editor hiện tại, không phải mô
Nếu chưa đọc 5 file này, chưa nên sửa behavior lớn của editor.
Docs nên đọc trước khi sửa editor:
- `src/uhm/doc/editor_operations.md`
- `src/uhm/doc/editor_data_roles.md`
- `src/uhm/doc/editor_snapshot_contract.md`
- `src/uhm/doc/editor_manual_test_checklist.md`
- `src/uhm/doc/editor_replay_actions.md`
## 2. Cấu trúc thư mục nên ưu tiên hiểu
- `src/uhm/components/editor/`
@@ -40,14 +48,17 @@ Editor có 3 tầng dữ liệu:
1. `baselineSnapshot`
- snapshot gốc của session
2. `initialData`
2. `baselineFeatureCollection`
- `FeatureCollection` rehydrate từ snapshot đó
3. `draft`
- seed/reset cho `useEditorState()`
3. `mainDraft`
- working copy để user sửa trên map
Map không render trực tiếp `mainDraft` mọi lúc. Page tạo `mapRenderDraft` từ `mainDraft`/`replayDraft`/preview draft sau khi áp timeline/replay filter, rồi truyền xuống `Map` dưới prop `renderDraft`. `labelContextDraft` chỉ dùng để lookup label, không được dùng để quyết định geometry nào hiện trên map.
Khi commit:
- geometry đi từ `draft`
- geometry đi từ `mainDraft`
- entity/wiki/link đi từ snapshot collections
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
@@ -150,9 +161,10 @@ Nghĩa là:
Một số nguyên tắc nên giữ:
- dùng `draftRef`/refs trong map engines để tránh rebind handler vô ích
- dùng `renderDraftRef`/refs trong map engines để tránh rebind handler vô ích
- giữ component panel càng dumb càng tốt, logic patch state đặt ở page/hooks
- khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết
- khi cần undo cho replay script, đi qua `editor.mutateActiveReplay()` hoặc replay collection helper hiện có
- hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng
## 12. Chỗ dễ gây hiểu nhầm khi debug
@@ -173,7 +185,7 @@ Không phải lúc nào cũng là bug render layer.
### Selection mất
Khi timeline filter làm geometry đang chọn không còn visible, page sẽ tự cắt `selectedFeatureIds`.
Selection hiện bám theo `editor.draft`, không theo `mapRenderDraft`. Vì vậy geometry đang chọn có thể bị timeline filter ẩn khỏi map nhưng panel metadata vẫn đọc được draft gốc.
## 13. Nên test gì sau khi sửa
+114
View File
@@ -0,0 +1,114 @@
# UHM Editor - vai trò dữ liệu dễ nhầm
Tài liệu này là glossary ngắn để người sửa code và AI không nhầm các `FeatureCollection`/snapshot gần tên nhau trong editor.
## Luật đọc nhanh
- `mainDraft` là dữ liệu geometry chính để edit và commit.
- `mapRenderDraft` là dữ liệu đã lọc để render map.
- `labelContextDraft` chỉ để lookup label, không quyết định render.
- `baselineFeatureCollection` chỉ để seed/reset session hiện tại.
- `baselineSnapshot` là snapshot gốc để so dirty và build commit delta.
- Các collection `snapshot*` là state hiện tại của snapshot, không phải danh sách delta thô.
## Geometry draft
### `baselineFeatureCollection`
FeatureCollection gốc của phiên editor hiện tại. Nó được tạo từ `baselineSnapshot.editor_feature_collection` khi mở project/restore commit, hoặc từ `EMPTY_FEATURE_COLLECTION` khi project chưa có commit.
Khi field này đổi, `useEditorState()` reset `mainDraft`, rebuild `initialMapRef`, và clear undo stack.
### `mainDraft`
Working copy geometry chính. Đây là nguồn commit cho geometry và là nơi các thao tác create/update/delete/properties ghi vào.
Không dùng `mapRenderDraft` để commit vì `mapRenderDraft` có thể thiếu geometry do timeline/replay/preview filter.
### `editor.draft`
Draft active theo mode:
- mode thường: `editor.draft === mainDraft`
- mode `replay`: `editor.draft === replayDraft`
Panel metadata và selection dùng `editor.draft` để vẫn đọc được geometry ngay cả khi map filter đang ẩn geometry đó.
### `replayDraft`
FeatureCollection local hydrate từ `mainDraft` theo `activeReplayDraft.target_geometry_ids`. Nó chỉ phục vụ replay edit mode, không thay thế `mainDraft`.
### `mapRenderDraft`
FeatureCollection do page tạo ra để truyền vào `Map` prop `renderDraft`.
Nguồn có thể là:
- `editor.mainDraft` ở mode thường
- `editor.replayDraft` ở replay edit mode
- `previewSession.draft` đã áp hidden ids ở replay preview mode
Sau đó page có thể áp timeline filter. Đây là nguồn duy nhất quyết định geometry nào xuất hiện trên map.
### `renderDraft`
Tên prop trong `Map.tsx`/`useMapSync.ts`. Đây là `mapRenderDraft` sau khi truyền xuống component map.
### `renderDraftRef`
Ref của `renderDraft` trong map interaction. Ref này dùng cho hover/select/edit trên các geometry đang render/interact. Không nhầm với `draftRef` nội bộ trong `useEditorState()`.
## Label context
### `labelContextBaseDraft`
FeatureCollection gốc để build label context. Nó có thể là draft rộng hơn `mapRenderDraft` để label vẫn resolve được entity/geometry liên quan.
### `mapLabelContextDraft`
FeatureCollection đã enrich label/entity name từ `labelContextBaseDraft`.
Rule quan trọng: `mapLabelContextDraft` chỉ dùng cho label lookup. Nó có thể chứa geometry bị timeline filter ẩn, nên không được dùng để quyết định render source hoặc geometry visibility.
## Snapshot state
### `baselineSnapshot`
Snapshot gốc của session hiện tại. Dùng để so dirty và để `buildEditorSnapshot()` biết row nào là reference/binding/update/delete.
### `snapshotEntityRows`
Các entity row của snapshot hiện tại. Đây là rows cho payload `entities[]`, không phải entity catalog toàn hệ thống.
### `snapshotWikis`
Các wiki row của snapshot hiện tại. Đây là source truth cho wiki trong commit.
### `snapshotEntityWikiLinks`
Các link entity-wiki hiện tại của snapshot. Snapshot builder sẽ tự sinh operation phù hợp so với `baselineSnapshot.entity_wiki`.
## Binding và visibility
### `geometry_entity[]`
Join table persist quan hệ geometry-entity trong snapshot commit. `feature.properties.entity_ids` chỉ là field denormalized cho UI.
### `binding`
Field geometry-geometry binding trên feature. Binding này không tính là entity binding; geometry không có `entity_ids/entity_id` hợp lệ vẫn là orphan.
### `geometryVisibility`
Map local visibility override. Key có thể là geometry id hoặc semantic geo type key. Đây là UI-only, không đi snapshot.
### `applyGeometryBindingFilter`
Filter map theo selection/binding. Chỉ ảnh hưởng render trên map, không đổi draft và không đi snapshot.
## Guard rails
- Render path: `mapRenderDraft -> Map.renderDraft -> useMapSync(renderDraft) -> MapLibre sources`.
- Label path: `labelContextBaseDraft -> mapLabelContextDraft -> useMapSync(labelContextDraft)`.
- Commit path: `mainDraft + snapshotEntityRows + snapshotWikis + snapshotEntityWikiLinks + effectiveReplays -> buildEditorSnapshot()`.
- Orphan validation vẫn chạy trên `mainDraft`, không phụ thuộc map filter.
+26 -6
View File
@@ -3,6 +3,13 @@
Tài liệu này mô tả editor đang chạy tại `src/app/editor/[id]/page.tsx` và các panel liên quan trong `src/uhm/components/`.
Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại, không mô tả các tính năng chưa được nối dây.
Docs liên quan:
- `src/uhm/doc/editor_operations.md`: ma trận thao tác/undo/snapshot.
- `src/uhm/doc/editor_snapshot_contract.md`: contract commit snapshot.
- `src/uhm/doc/editor_manual_test_checklist.md`: checklist test tay.
- `src/uhm/doc/editor_replay_actions.md`: catalog action replay.
## 1. Cách mở editor
- `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải.
@@ -17,7 +24,7 @@ Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại
- `UndoListPanel`
- Khu vực giữa
- `Map`
- `TimelineBar` khi không ở `replay`
- `TimelineBar` khi không ở `replay`; trong `replay_preview` phụ thuộc action `timeline`
- Cột phải (`BackgroundLayersPanel`)
- Search hợp nhất
- Geometry Binding
@@ -40,6 +47,7 @@ Hai cột hai bên đều resize được bằng drag handle.
- `add-path`
- `add-circle`
- `replay`
- `replay_preview`
Ý nghĩa thực tế:
@@ -49,7 +57,8 @@ Hai cột hai bên đều resize được bằng drag handle.
- `add-line`: vẽ `LineString`.
- `add-path`: vẽ `LineString` có render arrow layer cho route.
- `add-circle`: kéo chuột để tạo polygon hình tròn, có `circle_center``circle_radius`.
- `replay`: hiện là chế độ tập trung vào một geometry và các geometry trong `binding`; chưa có hệ thống script replay UI/map như file schema tham chiếu.
- `replay`: chế độ tập trung vào một geometry và tập `target_geometry_ids`, có sidebar sửa stage/step/action, preview overlay và undo riêng cho session replay.
- `replay_preview`: chạy preview từ replay đang edit; action điều khiển camera/timeline/wiki/narrative overlay và hidden geometry ids.
## 4. Công cụ vẽ và phím điều khiển
@@ -161,14 +170,14 @@ Panel phải có `UnifiedSearchBar` với 3 loại search:
- `entity`
- tìm local + backend theo tên/mô tả
- nút `Add` sẽ thêm entity vào `snapshotEntities` dưới dạng `reference`
- nút `Add` sẽ thêm entity vào `snapshotEntityRows` dưới dạng `reference`
- `wiki`
- tìm backend theo title
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
- `geo`
- tìm geometry theo tên entity
- nút `Import` sẽ import geometry vào draft hiện tại
- đồng thời thêm entity tương ứng vào `snapshotEntities` nếu chưa có
- đồng thời thêm entity tương ứng vào `snapshotEntityRows` nếu chưa có
- import sẽ tự tắt timeline filter để geometry mới import không bị ẩn
## 9. Entity và binding
@@ -200,6 +209,14 @@ Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang c
- Bind/unbind với geometry khác trong project.
- Có nút focus để zoom vào geometry trong list binding.
- Có toggle `Filter`: map chỉ hiển thị geometry liên quan tới selection nếu filter binding đang bật.
- Row geometry hiển thị chip trạng thái trong panel:
- `no entity` nếu geometry chưa bind entity.
- `no time` nếu thiếu cả `time_start``time_end`.
- `partial time` nếu chỉ có một trong hai mốc thời gian.
- `timeline` hoặc `out timeline` khi timeline filter đang bật.
- `hidden`, `bound`, `new` theo trạng thái UI tương ứng.
- ID geometry không render trực tiếp trong row; ID chỉ nằm trong `title` tooltip của row/nút thao tác.
- Geometry mồ côi không có style riêng trên map. Cảnh báo nằm ở panel và validation commit/submit.
## 10. Wiki và entity-wiki
@@ -247,12 +264,14 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
- `+1` nếu danh sách wiki dirty
- `+1` nếu danh sách entity dirty
- `+1` nếu danh sách entity-wiki dirty
- `+1` nếu replay script dirty
### Commit
`commitSection()`:
- build snapshot từ `draft` + `snapshotEntities` + `snapshotWikis` + `snapshotEntityWikiLinks`
- build snapshot từ `mainDraft` + `snapshotEntityRows` + `snapshotWikis` + `snapshotEntityWikiLinks` + `effectiveReplays`
- chặn commit nếu không có thay đổi, còn orphan geometry, hoặc payload vượt guardrail kích thước
- gửi `snapshot_json` lên API tạo commit
- nếu thành công:
- reset baseline sang snapshot vừa commit
@@ -263,11 +282,13 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
- chỉ submit được khi project có `head_commit_id`
- không submit nếu còn thay đổi chưa commit
- không submit nếu còn orphan geometry
### Restore
`CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là:
- chỉ chạy khi không còn pending changes
- load snapshot từ commit cũ vào FE
- không đổi head commit trên backend
@@ -293,4 +314,3 @@ Các mục sau không nên xem là tính năng hiện hành của editor:
- import/export wiki JSON chuyên biệt như một workflow riêng
- bộ shortcut toàn cục kiểu `Ctrl+S`, `Ctrl+Z`, `Ctrl+Y`
- workflow duyệt `Approved/Rejected` được render đầy đủ trong editor page
- hệ thống replay script theo `replays[]` trong schema snapshot
+131
View File
@@ -0,0 +1,131 @@
# UHM Editor - manual test checklist
Cập nhật: 2026-05-22.
Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/lint, nhưng bắt các lỗi workflow mà static check khó thấy.
## 1. Preflight
- Mở `/editor/[id]` với một project có ít nhất một geometry/entity/wiki.
- Mở console browser, đảm bảo không có runtime error ngay khi load.
- Kiểm tra map render đủ geometry, panel trái/phải không overlap.
- Kiểm tra `UndoListPanel` ban đầu không có action lạ từ lần load.
## 2. Geometry create/edit/delete
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Vẽ polygon ở `draw` mode | Geometry mới được select, panel hiện `no entity``no time` |
| 2 | Undo | Polygon biến mất, undo stack giảm |
| 3 | Tạo point | Point render bằng icon geotype bình thường, không đổi màu riêng vì orphan |
| 4 | Apply type/time cho point | Panel đổi `no time`/`partial time` đúng theo input |
| 5 | Sửa vertex/circle nếu có geometry phù hợp | Undo khôi phục geometry cũ |
| 6 | Xóa một geometry | Geometry biến mất, undo khôi phục đúng vị trí trong list |
| 7 | Multi-select cùng shape và xóa | Undo khôi phục toàn bộ geometry đã xóa |
## 3. Geometry status panel
- Row không hiển thị ID trực tiếp.
- Hover row thấy tooltip có `ID: ...`.
- Geometry không entity hiện chip `no entity`.
- Geometry thiếu cả `time_start/time_end` hiện `no time`.
- Geometry thiếu một trong hai field time hiện `partial time`.
- Bật timeline filter:
- Geometry còn visible hiện chip `timeline`.
- Geometry bị lọc khỏi draft visible hiện chip `out timeline`.
- Eye button set `hidden`, map ẩn geometry và panel hiện chip `hidden`.
- `NewBadge` vẫn hiện cho geometry mới/import chưa persisted.
## 4. Entity và geometry-entity
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Search entity và Add vào project | Entity xuất hiện trong panel, undo gỡ entity ref |
| 2 | Tạo entity local | Entity mới xuất hiện, form reset, undo gỡ entity |
| 3 | Sửa entity name/time | Undo khôi phục metadata entity |
| 4 | Bind entity vào selected geometry | Chip `no entity` biến mất, undo trả lại trạng thái cũ |
| 5 | Unbind entity | Chip `no entity` hiện lại, commit bị chặn nếu geometry còn orphan |
| 6 | Multi-select khác shape rồi bind entity | UI báo không thể bind nhiều geometry khác loại |
## 5. Geometry-geometry binding
- Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`.
- Panel hiện chip `bound` cho geometry liên quan.
- Toggle Filter: map chỉ hiện selection, selected children và parent/root phù hợp.
- Undo bind/unbind geometry phải khôi phục `properties.binding`.
- Bind geometry-geometry không làm mất chip `no entity` nếu geometry vẫn chưa bind entity.
## 6. Wiki và entity-wiki
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Search wiki và Add | Wiki ref xuất hiện, undo gỡ wiki ref |
| 2 | Tạo/sửa wiki local | Undo khôi phục danh sách/wiki content |
| 3 | Bind entity-wiki | Link xuất hiện, undo khôi phục links |
| 4 | Xóa wiki đang có entity-wiki links | Wiki và links liên quan bị xóa cùng lúc |
| 5 | Undo xóa wiki | Wiki và entity-wiki links cùng trở lại |
| 6 | Insert wiki link trong editor | Link nằm trong doc sau khi lưu wiki |
## 7. Replay
- Chọn geometry có entity, bấm replay.
- Replay mở với MAIN geo và các target ids liên quan binding.
- Tạo stage, tạo step, đổi duration.
- Thêm narrative action `set_title``set_descriptions`.
- Thêm map action `set_time_filter`, `show_labels`, `hide_labels`.
- Thêm geo action `fly_to_geometries`, `hide_geometries`, `show_geometries`.
- Undo trong replay mode chỉ undo replay session, không undo main geometry.
- Play preview:
- Step selection chạy đúng thứ tự.
- Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản.
- Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo.
## 8. Import GEO từ search
- Search GEO theo entity.
- Import một geometry chưa có trong draft.
- Kỳ vọng:
- Timeline filter tự tắt.
- Geometry được select.
- Entity ref được thêm nếu chưa có.
- Undo gỡ cả geometry và entity ref nếu entity ref được tạo trong cùng action.
- Import lại cùng GEO:
- Không tạo duplicate geometry.
- Chỉ select geometry đã có.
## 9. Commit và restore
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Commit khi không có thay đổi | Báo không có thay đổi |
| 2 | Commit khi còn orphan geometry | Bị chặn, select orphan đầu tiên, panel entity báo chưa bind |
| 3 | Bind entity rồi commit | Commit thành công, undo stack cleared, pending count về 0 |
| 4 | Kiểm snapshot commit | Có `geometries`, `geometry_entity`, `entities`, `wikis`, `entity_wiki`, `replays` đúng thay đổi |
| 5 | Restore commit cũ | Draft/snapshot panels reset theo commit |
## 10. Submit
- Khi còn pending changes, submit phải bị chặn và yêu cầu commit trước.
- Khi còn orphan geometry, submit bị chặn giống commit.
- Khi đã commit sạch và không orphan, submit tạo submission id/status.
- Nếu project bị pending submission lock, banner unlock hoạt động và mở lại project.
## 11. UI-only checks
Các thao tác sau không được thêm undo action và không làm tăng pending save count:
- Đổi timeline year/filter.
- Toggle background layers.
- Hide/show geometry local.
- Focus geometry từ panel.
- Resize panel.
- Search query.
- Pick/paste/remove image overlay trace.
- Replay preview play/stop/reset.
## 12. Final smoke
- `npx tsc --noEmit --pretty false`.
- Targeted eslint cho file vừa sửa.
- `git diff --check`.
- Nếu sửa frontend UI lớn: mở dev server và test ít nhất desktop viewport.
+200
View File
@@ -0,0 +1,200 @@
# UHM Editor - ma trận thao tác
Cập nhật: 2026-05-22.
Tài liệu này là checklist thao tác cho editor ở `/editor/[id]`. Mục tiêu là trả lời nhanh 4 câu hỏi khi thêm hoặc audit một tính năng:
- Người dùng thao tác ở đâu?
- State nào bị đổi?
- Có cần undo không, undo đang dùng action nào?
- Commit snapshot có bị ảnh hưởng không?
Nguồn chính:
- `src/app/editor/[id]/page.tsx`
- `src/app/editor/[id]/featureCommands.ts`
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
## 1. Quy ước phân loại
### Cần undo
Một thao tác cần undo nếu nó đổi dữ liệu sẽ đi vào commit snapshot hoặc đổi draft geometry chính:
- `mainDraft.features`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays`
- `activeReplayDraft.detail`
### Không cần undo
Một thao tác không cần undo nếu nó chỉ đổi trạng thái xem/điều hướng tạm thời:
- `mode`
- selection/focus/hover
- timeline year/filter UI
- background layer visibility
- geometry visibility local
- image trace overlay
- resize panel
- search query/result
- status message
### Undo action hiện có
| Action | Phạm vi | Ý nghĩa |
| --- | --- | --- |
| `create` | main draft | Gỡ geometry vừa tạo |
| `delete` | main draft | Khôi phục geometry đã xóa, có `index` để trả về vị trí cũ |
| `update` | main draft | Khôi phục `geometry` trước khi sửa vertex/circle |
| `properties` | main draft | Khôi phục `feature.properties` trước khi patch |
| `snapshot_entities` | snapshot | Khôi phục collection entity snapshot |
| `snapshot_wikis` | snapshot | Khôi phục collection wiki snapshot |
| `snapshot_entity_wiki` | snapshot | Khôi phục collection entity-wiki snapshot |
| `replay` | replay | Khôi phục một replay theo geometry id |
| `replays` | replay collection | Khôi phục toàn bộ `replays[]` |
| `replay_session` | replay mode | Khôi phục `activeReplayDraft` trong phiên replay |
| `group` | tổng hợp | Gom nhiều undo action thành một thao tác logic |
## 2. Geometry draft
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Vẽ polygon | `draw` mode, map drawing engine | Thêm feature vào `mainDraft` | `create` | `geometries[]`, `geometry_entity[]` nếu sau đó bind entity | Feature mới mặc định `type: country`, `geometry_preset: polygon`, chưa có entity |
| Tạo point | `add-point` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: city`, `geometry_preset: point` |
| Vẽ line | `add-line` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: defense_line`, `geometry_preset: line` |
| Vẽ path/route | `add-path` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: attack_route`, render thêm arrow layer |
| Vẽ circle | `add-circle` mode | Thêm polygon có `circle_center`, `circle_radius` | `create` | Như trên | Mặc định `type: war`, `geometry_preset: circle-area` |
| Import GEO từ search | Search `geo`, nút import | Thêm feature vào `mainDraft`, thêm entity ref nếu thiếu | `group` gồm `snapshot_entities``create` khi cả hai đổi | `geometries[]` và entity ref | Tắt timeline filter để GEO vừa import không bị ẩn |
| Chọn geometry | Click map/panel | `selectedFeatureIds` | Không | Không | Chỉ là UI state |
| Focus geometry từ panel | `GeometryBindingPanel` row click | Selection, `geometryFocusRequest`, có thể kéo timeline draft year về `time_start` | Không | Không | Không đổi dữ liệu commit |
| Sửa vertex/circle | Map edit engine trong `select` | `feature.geometry` | `update` | `geometries[]` | Không hoạt động trong replay mode |
| Sửa type/time metadata | `SelectedGeometryPanel` apply | `feature.properties.type/time_start/time_end/geometry_preset` | `properties` hoặc `group` khi multi-select | `geometries[]` | Validate time parse được và `time_start <= time_end` |
| Xóa một geometry | Map delete hoặc selected panel | Xóa feature khỏi `mainDraft` | `delete`, có thể group với `replays` | `geometries[]`, `geometry_entity[]` delete delta | Prune replay/target ids liên quan geometry bị xóa |
| Xóa nhiều geometry | Bulk selected panel/map callback | Xóa nhiều feature | `group` nhiều `delete`, có thể kèm `replays` | Như trên | Undo khôi phục theo index cũ |
| Ẩn/hiện geometry local | Eye button, map hide callback | `geometryVisibility` | Không | Không | Local UI only, không đi snapshot |
| Geometry status panel | `GeometryBindingPanel` | Derived từ draft/timeline/visibility | Không | Không | Hiện `no entity`, `no time`, `partial time`, `timeline`, `out timeline`, `hidden`, `bound`, `new`; ID chỉ nằm trong tooltip |
## 3. Geometry binding
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Bind entity vào selected geometry | `ProjectEntityRefsPanel` checkbox | `entity_id`, `entity_ids`, `entity_name`, `entity_names` trên selected features | `properties` hoặc `group` | `geometry_entity[]` | Multi-select chỉ hợp lệ khi cùng shape type |
| Unbind entity | `ProjectEntityRefsPanel` checkbox | Các field entity trên feature | `properties` hoặc `group` | `geometry_entity[]` delete delta nếu baseline có link | Commit/submit chặn geometry không còn entity |
| Bind geometry-geometry | `GeometryBindingPanel` lock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Binding geometry không thay thế entity binding |
| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Không ảnh hưởng `geometry_entity[]` |
| Bind nhiều geometry vào target | Map bind callback | `binding` của target geometry | `properties` | `geometries[].binding` | Tự bỏ target id khỏi source ids |
| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/binding |
## 4. Entity snapshot
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Add entity ref từ search | Search `entity`, nút add | `snapshotEntityRows`, `entityCatalog` | `snapshot_entities` nếu collection đổi | `entities[]` với `source: ref`, `operation: reference` | Không gọi API create entity |
| Tạo entity local | `ProjectEntityRefsPanel` create form | `snapshotEntityRows`, `entityCatalog`, reset form | `snapshot_entities` | `entities[]` với `source: inline`, `operation: create` | Validate name bắt buộc, không trùng tên, time hợp lệ |
| Sửa entity trong project | Entity row edit | `snapshotEntityRows` | `snapshot_entities` | `entities[]` update/reference theo source | Validate name và time |
| Copy selected geometry time vào form entity | Entity panel button | Form state | Không | Không | Chỉ tiện ích UI |
## 5. Wiki và entity-wiki
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Add wiki ref từ search | Search `wiki`, nút add | `snapshotWikis`, active wiki request | `snapshot_wikis` nếu collection đổi | `wikis[]` với `source: ref`, `operation: reference` | Không fetch lại toàn bộ project |
| Tạo/sửa wiki local | `WikiSidebarPanel` | `snapshotWikis` | `snapshot_wikis` | `wikis[]` | `doc` ưu tiên HTML string, plaintext là fallback |
| Import HTML vào wiki | `WikiSidebarPanel` import | `snapshotWikis` sau khi lưu | `snapshot_wikis` | `wikis[]` | File import không tự commit |
| Export wiki | `WikiSidebarPanel` export | Không đổi editor state | Không | Không | Tạo file tải xuống phía browser |
| Xóa wiki khỏi snapshot | `WikiSidebarPanel` remove | `snapshotWikis` và các `snapshotEntityWikiLinks` trỏ tới wiki | `group` gồm `snapshot_wikis``snapshot_entity_wiki` | `wikis[]`, `entity_wiki[]` delta | Đây là thao tác kép, phải undo cùng nhau |
| Bind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` với `binding` hoặc `reference` theo baseline | Link mới dùng `operation: binding` |
| Unbind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` delete delta nếu baseline có link | Runtime chỉ remove row, snapshot builder sinh delta |
| Chèn wiki link trong editor Quill | Wiki toolbar custom link | `doc` của wiki đang sửa | `snapshot_wikis` khi lưu wiki | `wikis[].doc` | Link có thể là slug local/global hoặc marker `__missing__` |
## 6. Replay
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Vào replay mode | Selected geometry panel, replay button | `mode`, `activeReplayId`, `activeReplayDraft`, `replayDraft` | Không cho việc mở mode | Không trực tiếp | Nếu đổi replay đang mở, session cũ được flush |
| Tạo seed replay | `switchReplayContext` | `activeReplayDraft` với `geometry_id`, `target_geometry_ids`, `detail` | Không ngay lúc seed | `replays[]` khi mutate/flush | MAIN geo luôn đứng đầu target list |
| Sửa replay detail | `ReplayTimelineSidebar`, `ReplayEffectsSidebar` | `activeReplayDraft.detail` | `replay_session` | `replays[].detail` qua `effectiveReplays` | Replay mode không mutate geometry |
| Undo trong replay mode | Undo button khi `mode === replay` | `activeReplayDraft` | Pop `replayUndoStack` | Có nếu session còn dirty | Undo chính và undo replay tách stack |
| Thoát/chuyển replay | Exit hoặc đổi context | Flush `activeReplayDraft` vào `replays[]` | `replay` nếu flush có đổi | `replays[]` | Commit đọc `effectiveReplays`, nên không cần thoát replay trước commit |
| Xóa geometry có replay | Delete geometry | `mainDraft`, có thể prune `replays[]` | `group` với `replays` | `geometries[]`, `replays[]` | Target ids bị xóa cũng được prune |
| Preview replay | Preview overlay | Preview session, hidden ids, preview year | Không | Không | Chỉ là mô phỏng UI/map |
## 7. Timeline, map style và panel status
| Thao tác | State đổi | Undo | Commit | Ghi chú |
| --- | --- | --- | --- | --- |
| Đổi timeline year | `timelineDraftYear` | Không | Không | Client-side filter |
| Bật/tắt timeline filter | `timelineFilterEnabled` | Không | Không | New geometry trong session vẫn visible |
| Geometry bị timeline lọc | Derived `mapRenderDraft` | Không | Không | Panel hiện `timeline` hoặc `out timeline`; selection/panel metadata vẫn đọc `editor.draft` |
| Geometry mồ côi | Derived từ `normalizeFeatureEntityIds(feature).length === 0` | Không riêng | Commit/submit bị chặn | Map không đổi màu riêng cho orphan; panel hiện `no entity` |
| Thiếu time | Derived từ `time_start/time_end` | Không riêng | Vẫn commit được | Panel hiện `no time` hoặc `partial time` |
| Selected style trên map | Feature-state selected | Không | Không | Vẫn giữ highlight selected màu xanh |
| Background layer visibility | `backgroundVisibility`, localStorage | Không | Không | UI preference |
## 8. Image overlay trace
| Thao tác | State đổi | Undo | Commit | Ghi chú |
| --- | --- | --- | --- | --- |
| Pick image overlay | `imageOverlay`, object URL | Không | Không | Overlay để trace, không vào snapshot |
| Paste image overlay | `imageOverlay`, object URL | Không | Không | Cần browser clipboard permission |
| Đổi opacity | `imageOverlay.opacity` | Không | Không | UI only |
| Dời/scale bằng keyboard | `imageOverlay.coordinates` | Không | Không | UI only |
| Remove overlay | `imageOverlay = null`, revoke URL | Không | Không | Không ảnh hưởng draft |
## 9. Project lifecycle
| Thao tác | Entry point | State đổi | Undo | Snapshot/API | Validation |
| --- | --- | --- | --- | --- | --- |
| Mở project | Project panel/open route | Reset session state, `baselineFeatureCollection`, baseline snapshot | Không | Fetch project/commit snapshot | Nếu có pending changes khi đổi project thì confirm bỏ thay đổi |
| Tạo project mới | Project panel | Project list, active project, baseline empty | Không | API create project | Title bắt buộc |
| Commit | `CommitPanel` | Baseline snapshot, `baselineFeatureCollection`, clear undo/changes | Không undo sau commit | `createProjectCommit` với `buildEditorSnapshot` | Chặn nếu không có thay đổi, chặn orphan geometry, guard payload lớn |
| Submit | Submit modal | Submission status | Không | `submitSection` | Chỉ submit khi không pending save và không orphan geometry |
| Restore commit | Commit history | Reset draft/snapshot/session theo commit | Không | Fetch/convert commit snapshot | Chặn nếu còn pending changes; không đổi head trên BE |
| Delete pending submission lock | Banner unlock | `blockedPendingSubmissionId`, mở lại project | Không | `deleteSubmission` | Dùng khi backend báo project đang bị pending submission khóa |
## 10. Undo coverage checklist
Khi thêm một thao tác mới, kiểm theo thứ tự này:
1. Thao tác có đổi `mainDraft`, snapshot collection hoặc replay detail không?
2. Nếu có, nó phải đi qua một trong các API undoable:
- `editor.createFeature`
- `editor.createFeatureWithSnapshotEntityRows`
- `editor.updateFeature`
- `editor.deleteFeature` hoặc `editor.deleteFeatures`
- `editor.patchFeatureProperties` hoặc `editor.patchFeaturePropertiesBatch`
- `editor.setSnapshotEntityRows`
- `editor.setSnapshotWikis`
- `editor.setSnapshotEntityWikiLinks`
- `editor.setSnapshotWikisAndEntityWikiLinks`
- `editor.mutateActiveReplay`
3. Nếu thao tác đổi nhiều vùng state trong cùng một ý nghĩa người dùng, dùng `group`.
4. Nếu xóa geometry, kiểm replay target/replay collection có cần prune không.
5. Nếu xóa wiki, kiểm entity-wiki links trỏ tới wiki đó có cần xóa cùng undo không.
6. Nếu thao tác có thể tạo geometry không entity, commit/submit guard vẫn phải bắt được.
7. Nếu thao tác chỉ đổi UI view/filter/focus, ghi rõ là không undo và không snapshot.
## 11. Snapshot checklist
Khi một thao tác cần đi vào commit, kiểm output snapshot:
- Geometry body nằm trong `geometries[]`.
- Geometry-entity relation nằm trong `geometry_entity[]`, không chỉ trong `feature.properties.entity_ids`.
- Entity rows nằm trong `entities[]`.
- Wiki rows nằm trong `wikis[]`.
- Entity-wiki rows nằm trong `entity_wiki[]`.
- Replay script nằm trong `replays[]`, không lưu `replayDraft`.
- Generate-only fields trên feature như `entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_label_candidates`, `time_start`, `time_end`, `binding`, `type` được snapshot builder xử lý/loại bỏ đúng chỗ trước API payload.
## 12. Các thao tác cần audit lại nếu editor đổi lớn
- Multi-select khác shape hiện bị chặn ở bind entity/geometry, nhưng selected panel vẫn phải giữ rule này nếu thêm action mới.
- Timeline filter đang là client-side, nếu sau này fetch theo timeline từ backend thì `timelineStatus` trong panel cần đổi nguồn truth.
- Image overlay hiện không persist. Nếu cần lưu overlay vào project, phải thêm snapshot schema và undo.
- Background visibility hiện là localStorage. Nếu cần lưu theo project/user, phải tách khỏi nhóm UI-only.
- Replay mode hiện không mutate geometry. Nếu cho sửa geometry trong replay, phải thiết kế lại undo và commit boundary.
+194
View File
@@ -0,0 +1,194 @@
# UHM Editor - replay actions catalog
Cập nhật: 2026-05-22.
Tài liệu này mô tả action catalog của replay editor/preview hiện tại. Shape chuẩn nằm ở `src/uhm/types/projects.ts`; dispatcher runtime nằm ở `src/uhm/lib/replay/replayDispatcher.ts`.
## 1. Replay shape
```ts
type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
type ReplayAction<T> = {
function_name: T;
params: unknown[];
};
```
Ghi chú:
- `use_narrow_function` là tên field hiện tại cho nhóm narrative.
- `params` là tuple positional, không phải object schema.
- `target_geometry_ids` là source truth cho replay draft; không persist `replayDraft`.
- `detail_time_start/detail_time_stop` là string theo form replay hiện tại, không phải `time_start/time_end` số của geometry.
## 2. Runtime execution order
Preview flatten replay thành danh sách step theo thứ tự stage/step.
Trong mỗi step, dispatcher chạy các group action từ step hiện tại. Duration của step quyết định thời gian chờ trước step tiếp theo. Preview state có thể đổi:
- map camera/labels
- timeline visible/filter/year
- hidden geometry ids
- title/descriptions/subtitle/dialog/image/toast
- wiki sidebar/open wiki
- playback speed
Stop/reset preview khôi phục presentation state và một phần map/timeline baseline.
## 3. UI actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview |
| `layer_panel` | `[visible: boolean]` | No-op hiện tại |
| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki |
| `zoom_panel` | `[visible: boolean]` | No-op hiện tại |
| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
| `toast` | `[message: string]` | Hiện toast tạm thời |
| `wiki_header` | `[headerId: string]` | No-op hiện tại |
| `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview |
Legacy shape vẫn được dispatcher đọc:
```ts
{ function_name: "UI", params: [optionName, ...payload] }
```
Shape mới nên dùng trực tiếp:
```ts
{ function_name: "timeline", params: [true] }
```
## 4. Map actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `set_camera_view` | `[state]` | `map.easeTo` center/zoom/pitch/bearing/duration |
| `set_time_filter` | `[year: number]` | Set replay preview timeline year |
| `enable_timeline_filter` | `[]` | Bật timeline filter |
| `disable_timeline_filter` | `[]` | Tắt timeline filter |
| `toggle_labels` | `[visible: boolean]` | Legacy labels toggle |
| `show_labels` | `[]` | Hiện symbol text labels |
| `hide_labels` | `[]` | Ẩn symbol text labels |
| `show_all_geometries` | `[]` | Clear hidden geometry ids |
| `reset_camera_north` | `[]` | Set bearing về 0 |
`set_camera_view` chấp nhận center dạng `[lng, lat]` hoặc `{ lng, lat }`.
## 5. Geo actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `fly_to_geometry` | `[geometryId]` | Legacy: fly tới một geometry |
| `fly_to_geometries` | `[geometryIds, duration?]` | Fit/fly tới nhiều geometry |
| `set_geometry_visibility` | `[geometryIds, visible]` | Legacy: show/hide theo boolean |
| `show_geometries` | `[geometryIds]` | Bỏ ids khỏi hidden set |
| `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set |
| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry |
| `orbit_camera_around_geometry` | `[geometryId, zoom?, pitch?, turns?, duration?]` | Ease camera quanh bbox geometry |
| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | No-op trong dispatcher hiện tại |
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | No-op trong dispatcher hiện tại |
| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | No-op trong dispatcher hiện tại |
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | No-op trong dispatcher hiện tại |
| `follow_geometry_path` | `[geometryId, duration?]` | Legacy: fly theo một path bằng fit/fly |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, padding?]` | Hiện dùng fly/fit tới nhiều geometry |
| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
Các action visual effect no-op vẫn có trong composer để giữ schema và chuẩn bị cho runtime effect sau này.
## 6. Narrative actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `set_title` | `[title: string]` | Set title overlay |
| `clear_title` | `[]` | Clear title |
| `set_descriptions` | `[text: string]` | Set description overlay |
| `clear_descriptions` | `[]` | Clear descriptions |
| `show_dialog_box` | `[avatar, text, side, speaker?]` | Hiện dialog, side là `left` hoặc `right` |
| `clear_dialog_box` | `[]` | Clear dialog |
| `display_historical_image` | `[url, caption?]` | Hiện image overlay lịch sử |
| `clear_historical_image` | `[]` | Clear image |
| `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle |
| `clear_step_subtitle` | `[]` | Clear subtitle |
## 7. Composer shortcuts hiện có
Map shortcuts:
- `show_labels`
- `hide_labels`
- `enable_timeline_filter`
- `disable_timeline_filter`
- `set_time_filter`
- `reset_camera_north`
- `show_all_geometries`
Geo shortcuts:
- `fly_to_geometries`
- `follow_geometries_path`
- `show_geometries`
- `hide_geometries`
- `pulse_geometry`
- `animate_dashed_border`
- `orbit_camera_around_geometry`
- `show_geometry_label`
- `dim_other_geometries`
- `set_geometry_style`
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
## 8. Normalization và migration
Khi load snapshot:
- Replay thiếu `geometry_id` có thể fallback từ `id`.
- `target_geometry_ids` được normalize/dedupe, MAIN geo đứng đầu.
- Snapshot cũ có `replay_features` được chuyển thành `target_geometry_ids`.
- UI legacy action `{ function_name: "UI", params: [...] }` được normalize sang option action.
- Unknown action/function bị bỏ qua trong normalize/dispatcher.
- Normalizer snapshot hiện giữ các action đang có trong type/UI, gồm `close_wiki_panel`, `show_all_geometries` và các narrative `clear_*`.
## 9. Undo và commit boundary
- Replay mode dùng `replayUndoStack`, tách khỏi main undo.
- Sửa stage/step/action đi qua `editor.mutateActiveReplay`.
- Mỗi mutation tạo `replay_session` undo action.
- Thoát hoặc chuyển replay flush session vào `replays[]`.
- Commit đọc `editor.effectiveReplays`, nên có thể commit khi vẫn đang ở replay mode.
- Replay mode hiện không cho create/update/delete geometry.
## 10. Checklist khi thêm replay action
1. Thêm function name vào `src/uhm/types/projects.ts`.
2. Thêm label/summary trong `ReplayTimelineSidebar`.
3. Thêm composer hoặc shortcut trong `ReplayEffectsSidebar`.
4. Thêm runtime trong `replayDispatcher.ts` và action module phù hợp.
5. Thêm normalize support trong `editorSnapshot.ts`.
6. Xác định action có cần reset khi stop preview không.
7. Cập nhật file này và `commit_snapshot.ts`.
+244
View File
@@ -0,0 +1,244 @@
# UHM Editor - snapshot contract
Cập nhật: 2026-05-22.
Tài liệu này mô tả ranh giới dữ liệu giữa editor runtime và commit payload. Nếu `editor_operations.md` trả lời "thao tác nào đổi gì", file này trả lời "commit gửi shape nào và vì sao".
Nguồn chính:
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- `src/uhm/doc/commit_snapshot.ts`
- `src/uhm/types/projects.ts`
- `src/uhm/types/geo.ts`
## 1. Luồng build commit
Luồng hiện tại:
1. `commitSection()` kiểm tra project đang mở, pending changes và orphan geometry.
2. `editor.buildPayload()` lấy geometry diff để xác định operation.
3. `buildEditorSnapshot()` nhận `mainDraft`, snapshot collections, `effectiveReplays`, `previousSnapshot`.
4. Commit API nhận snapshot đã qua `toApiEditorSnapshot()`.
5. Sau commit thành công, FE chuyển snapshot mới về session shape bằng `toEditorSessionSnapshot()` và reset baseline.
Payload API:
```ts
{
snapshot_json: EditorSnapshot;
edit_summary: string;
}
```
`toApiEditorSnapshot()` hiện normalize thêm:
- `time_start/time_end`: ép về `number|null` nếu field tồn tại ở feature/entity/geometry.
- `geometries[].type`: đổi type key FE sang backend type code string hoặc `null`.
- `replays[]`: normalize `id`, `geometry_id`, `target_geometry_ids`, `detail`.
## 2. Root snapshot shape
| Field | Nguồn runtime | Ý nghĩa |
| --- | --- | --- |
| `editor_feature_collection` | Clone từ `mainDraft` đã bỏ field generate-only | FeatureCollection runtime phục vụ load lại editor |
| `entities` | `snapshotEntityRows` + entity ids phát hiện từ geometry | Entity rows inline/ref |
| `geometries` | `mainDraft.features` + deleted ids từ diff | Geometry rows có operation |
| `geometry_entity` | `feature.properties.entity_ids/entity_id` so với baseline | Join table geometry-entity |
| `wikis` | `snapshotWikis` so với baseline | Wiki rows inline/ref/delete |
| `entity_wiki` | `snapshotEntityWikiLinks` so với baseline | Join table entity-wiki |
| `replays` | `editor.effectiveReplays` | Script replay, không chứa `replayDraft` |
Root fields optional ở type vì FE còn phải đọc snapshot cũ/partial, nhưng commit mới nên sinh đủ các collection có liên quan.
## 3. Geometry contract
### `geometries[]`
Mỗi feature trong `mainDraft.features` sinh một row:
| Field | Rule |
| --- | --- |
| `id` | `String(feature.properties.id)` |
| `source` | Luôn `"inline"` cho geometry đang tồn tại trong draft |
| `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff |
| `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API |
| `draw_geometry` | `feature.geometry` |
| `binding` | `normalizeFeatureBindingIds(feature)` |
| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` |
| `bbox` | BBox tính từ geometry, hoặc `null` |
Geometry đã bị xóa sinh row:
```ts
{
id,
source: "ref",
operation: "delete"
}
```
### Operation rule
`operation` của geometry đang tồn tại được tính theo thứ tự:
- Nếu snapshot trước đã đánh dấu row này `create`, giữ `create`.
- Nếu không có previous feature và đang có previous snapshot hoặc feature chưa persisted, là `create`.
- Nếu id nằm trong geometry changes hoặc feature khác previous snapshot, là `update`.
- Còn lại là `reference`.
## 4. FeatureCollection runtime contract
`editor_feature_collection` giữ geometry để load lại editor, nhưng trước khi đưa vào snapshot FE xóa các field generate-only khỏi `feature.properties`:
- `type`
- `time_start`
- `time_end`
- `binding`
- `entity_id`
- `entity_ids`
- `entity_name`
- `entity_names`
- `entity_label_candidates`
- `entity_type_id`
Các field này được lưu ở collection chuẩn hơn:
- `type/time/binding` nằm ở `geometries[]`.
- entity relation nằm ở `geometry_entity[]`.
- entity label/name được hydrate lại từ `entities[]` và join table khi load.
## 5. Geometry-entity contract
Join table chính là `geometry_entity[]`, không phải field denormalized trên feature.
Runtime source:
- `normalizeFeatureEntityIds(feature)`
- Ưu tiên `entity_ids[]` hợp lệ.
- Fallback `entity_id` nếu `entity_ids` rỗng.
Build rule:
- Link hiện có trong baseline và vẫn còn trong draft: `operation: "reference"`.
- Link mới trong draft: `operation: "binding"`.
- Link có trong baseline nhưng không còn trong draft: `operation: "delete"`.
Rows được dedupe/sort theo `geometry_id`, rồi `entity_id`.
Commit/submit hiện chặn nếu có geometry không có entity ids hợp lệ. Geometry-geometry `binding` không được tính là đã bind entity.
## 6. Entity contract
`entities[]` được build từ:
- `snapshotEntityRows` hiện tại.
- Entity ids xuất hiện trong `geometry_entity[]` nhưng chưa có row entity, được bổ sung row ref tối thiểu.
Row tối thiểu:
```ts
{
id: string;
source: "inline" | "ref";
operation?: "create" | "update" | "delete" | "reference";
name?: string;
description?: string | null;
time_start?: number;
time_end?: number;
}
```
Quy ước:
- Entity backend/search thêm vào snapshot dùng `source: "ref"`, `operation: "reference"`.
- Entity tạo local dùng `source: "inline"`, `operation: "create"`.
- Sửa entity inline có thể giữ `create` nếu chưa commit hoặc thành `update`.
## 7. Wiki contract
`wikis[]` đến từ `snapshotWikis` so với baseline.
Row chính:
```ts
{
id: string;
source: "inline" | "ref";
operation?: "create" | "update" | "delete" | "reference";
title: string;
slug?: string | null;
doc: string | null;
}
```
Rule xóa:
- Nếu wiki có trong baseline nhưng không còn trong `snapshotWikis`, snapshot builder thêm row `operation: "delete"`.
- Khi UI xóa wiki, FE cũng xóa các `snapshotEntityWikiLinks` trỏ tới wiki đó trong cùng undo group.
`doc` hiện ưu tiên HTML string. Plaintext là fallback cho dữ liệu cũ.
## 8. Entity-wiki contract
Runtime source là `snapshotEntityWikiLinks`.
Build rule tương tự geometry-entity:
- Link có trong baseline và vẫn còn: `reference`.
- Link mới: `binding`.
- Link bị remove so với baseline: `delete`.
Rows được dedupe/sort theo `entity_id`, rồi `wiki_id`.
## 9. Replay contract
Commit gửi `replays[]` từ `editor.effectiveReplays`.
Canonical shape:
```ts
{
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
}
```
Rule:
- `id` hiện bằng `geometry_id`.
- `target_geometry_ids` được normalize, MAIN geo đứng đầu.
- `detail` là danh sách stage/step/action.
- Không gửi `replayDraft` hoặc `replay_features`.
Snapshot cũ có `replay_features` được FE migrate sang `target_geometry_ids` khi load.
## 10. Validation trước commit/submit
FE chặn commit nếu:
- Chưa mở project.
- Không có pending changes.
- Có orphan geometry.
- Payload JSON vượt guardrail kích thước hiện tại khoảng 3.5MB.
FE chặn submit nếu:
- Project chưa có head commit.
- Còn pending changes chưa commit.
- Có orphan geometry.
Missing/partial time hiện chỉ là trạng thái panel, không chặn commit.
## 11. Checklist khi đổi snapshot
Khi thêm field/collection mới:
1. Cập nhật type runtime trong `src/uhm/types`.
2. Cập nhật `src/uhm/doc/commit_snapshot.ts`.
3. Cập nhật `buildEditorSnapshot()``toEditorSessionSnapshot()` nếu field cần round-trip.
4. Cập nhật `toApiEditorSnapshot()` nếu backend cần shape khác runtime.
5. Cập nhật undo nếu thao tác chỉnh field đó là user-facing persistent action.
6. Cập nhật dirty detection/pending save count nếu collection mới độc lập với geometry.
7. Cập nhật `editor_operations.md` và manual checklist.
+9 -2
View File
@@ -8,6 +8,7 @@ Nguồn thật:
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
@@ -15,7 +16,7 @@ Replay mode hiện tại có 2 lớp state:
- `activeReplayDraft`
-`BattleReplay` đang chỉnh
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
- chỉ chứa `id`, `geometry_id`, `target_geometry_ids`, `detail`
- `replayDraft`
-`FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
- chỉ dùng để map/render/select trong replay mode
@@ -125,6 +126,10 @@ Nên khi `mode === "replay"`:
- `editor.draftRef` trỏ vào `replayDraftRef`
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
`editor.draftRef` ở đây là ref nội bộ của editor state; map interaction dùng tên `renderDraftRef` để tránh nhầm với draft commit chính.
Khi `mode === "replay_preview"`, page dùng `previewSession.draft` và replay preview state để tạo `mapRenderDraft` rồi render/ẩn geometry. Mode này không mutate `replayDraft` hoặc `mainDraft`.
## 7. Replay mode còn sửa geometry không
Không.
@@ -132,7 +137,7 @@ Không.
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
- `createFeature`
- `createFeatureWithSnapshotEntities`
- `createFeatureWithSnapshotEntityRows`
- `patchFeatureProperties`
- `patchFeaturePropertiesBatch`
- `updateFeature`
@@ -161,6 +166,8 @@ Undo replay vẫn riêng ở:
- `replayUndoStack`
Danh sách action và tuple `params` nằm ở `editor_replay_actions.md`.
## 9. Khi nào replay được flush về `replays[]`
`activeReplayDraft` chỉ là session đang mở.
+68 -20
View File
@@ -9,7 +9,7 @@ Editor đang tách làm hai khối:
- `useEditorSessionState()`
- state UI, session, form, project, timeline, background, wiki
- `useEditorState(initialData, snapshotUndo)`
- `useEditorState(baselineFeatureCollection, snapshotUndo)`
- state draft hình học, diff và undo
Nói ngắn gọn:
@@ -19,26 +19,34 @@ Nói ngắn gọn:
## 2. State geometry trung tâm
### `initialData`
### `baselineFeatureCollection`
- Nằm ở `useEditorSessionState()`
-`FeatureCollection` đang được nạp vào editor khi mở project hoặc restore commit
-`FeatureCollection` baseline được nạp vào editor khi mở project hoặc restore commit
- Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng
### `draft`
### `mainDraft`
- Nằm trong `useEditorState()`
-nguồn dữ liệu render trực tiếp cho `Map`
-working copy geometry chính dùng cho edit/commit
- Mọi thao tác create/update/delete geometry đều đi qua đây
### `editor.draft`
- Là draft đang active theo mode
- Ở mode thường trỏ tới `mainDraft`
- Ở mode `replay` trỏ tới `replayDraft`
- Panel metadata/selection đọc từ đây, không đọc từ `mapRenderDraft`
### `draftRef`
- Bản ref của `draft`
- Được dùng trong event handlers của map engine để luôn đọc được state mới nhất mà không phải rebind callback liên tục
- Ref nội bộ tương ứng với draft trong `useEditorState()`
- Được dùng để luôn đọc được state mới nhất mà không phải rebind callback liên tục
- Không nhầm với `renderDraftRef` trong `Map.tsx`, vốn là dữ liệu đang render/interact trên map
### `initialMapRef`
- `Map<featureId, Feature>` tạo từ `initialData`
- `Map<featureId, Feature>` tạo từ `baselineFeatureCollection`
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
### `changes`
@@ -55,7 +63,7 @@ Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay
### `changeCount`
- Số lượng geometry thay đổi hiện tại
- Được cộng thêm dirty state của wiki/entity/entity-wiki để tạo `pendingSaveCount`
- Được cộng thêm dirty state của wiki/entity/entity-wiki/replay để tạo `pendingSaveCount`
## 3. Undo state
@@ -70,12 +78,17 @@ Kiểu action hiện có:
- `snapshot_entities`
- `snapshot_wikis`
- `snapshot_entity_wiki`
- `replay`
- `replays`
- `replay_session`
- `group`
Ý nghĩa:
- geometry create/delete/update/properties undo được trực tiếp trên `draft`
- snapshot entity/wiki/link undo được apply qua `snapshotUndo` API truyền vào `useEditorState`
- `replay`/`replays` undo các thay đổi script replay đã flush vào collection chính
- `replay_session` undo các thay đổi stage/step/action khi đang ở mode `replay`
- `group` dùng để gom nhiều thay đổi thành một thao tác undo logic
Editor hiện có `undo`, nhưng chưa có redo.
@@ -107,7 +120,23 @@ Editor hiện có `undo`, nhưng chưa có redo.
`geometryMetaForm.binding` hiện chủ yếu là giá trị hiển thị/đồng bộ UI, còn chỉnh sửa binding thật đi qua `GeometryBindingPanel`.
### 4.3. Project/session task state
### 4.3. Replay state
Replay state nằm trong `useEditorState()`:
- `replays`
- collection script đã flush vào state chính
- `activeReplayDraft`
- `BattleReplay` đang sửa trong mode `replay`
- `replayDraft`
- `FeatureCollection` hydrate từ `mainDraft + activeReplayDraft.target_geometry_ids`
- `effectiveReplays`
- `replays` cộng overlay của `activeReplayDraft` nếu session hiện tại đã đổi nhưng chưa flush
Undo của replay session dùng stack riêng khi `mode === "replay"`.
`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist.
### 4.4. Project/session task state
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
@@ -127,7 +156,7 @@ Ngoài ra còn có:
- `baselineSnapshot`
- `commitTitle`
### 4.4. Timeline state
### 4.5. Timeline state
`useTimelineState()` giữ:
@@ -139,7 +168,7 @@ Ngoài ra còn có:
Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`.
Không có fetch dữ liệu project theo `timelineYear`; timeline đang là client-side visibility filter.
### 4.5. Background/session UI
### 4.6. Background/session UI
`useBackgroundSessionState()` giữ:
@@ -148,7 +177,7 @@ Không có fetch dữ liệu project theo `timelineYear`; timeline đang là cli
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
### 4.6. Wiki/session state
### 4.7. Wiki/session state
`useWikiSessionState()` giữ:
@@ -159,11 +188,12 @@ Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisi
## 5. Snapshot state
Editor đang làm việc với 3 snapshot collection chính ngoài geometry:
Editor đang làm việc với các snapshot collection chính ngoài geometry:
- `snapshotEntities`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays` / `effectiveReplays`
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
@@ -193,12 +223,27 @@ Nó được cập nhật khi:
## 7. Derived state quan trọng trong page
### `timelineVisibleDraft`
### `mapRenderDraft`
-`draft` đã qua filter timeline nếu `timelineFilterEnabled = true`
-`FeatureCollection` duy nhất trong page quyết định geometry nào được truyền xuống map
- nguồn có thể là `mainDraft`, `replayDraft`, hoặc preview draft tùy mode
- đã qua filter timeline nếu `timelineFilterEnabled = true`
- đã qua replay preview hidden ids nếu đang preview
- geometry mới tạo trong session không bị timeline filter ẩn
### `snapshotEntitiesVisible`
### `labelContextBaseDraft` và `mapLabelContextDraft`
- chỉ dùng để enrich/lookup label entity cho map
- có thể chứa geometry bị `mapRenderDraft` lọc ra
- không được dùng để quyết định geometry nào render trên map
### `geometryChoices`
- nguồn dữ liệu cho `GeometryBindingPanel`
- thêm trạng thái derived như orphan entity, time completeness, timeline visibility, hidden/bound/new
- ID geometry không phải label chính của row, nhưng vẫn nằm trong tooltip/title
### `snapshotEntityRowsVisible`
- loại bỏ các row `delete`
- dedupe theo `id`
@@ -220,6 +265,7 @@ Nó được cập nhật khi:
- `+1` nếu wiki dirty
- `+1` nếu entities dirty
- `+1` nếu entity-wiki dirty
- `+1` nếu replay dirty
Đây là con số dùng trong UI commit, không phải số record backend chắc chắn sẽ thay đổi.
@@ -228,8 +274,9 @@ Nó được cập nhật khi:
Dirty check của:
- `snapshotWikis`
- `snapshotEntities`
- `snapshotEntityRows`
- `snapshotEntityWikiLinks`
- `editor.effectiveReplays`
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
@@ -262,7 +309,7 @@ Xảy ra khi:
Hiệu ứng:
- `initialData` đổi
- `baselineFeatureCollection` đổi
- `useEditorState()` reset `draft`
- `undoStack` bị clear
- baseline map được build lại
@@ -278,3 +325,4 @@ Hiệu ứng:
- timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering
- dirty count của commit không tương ứng một-một với số mutation backend
- map selection, binding filter và timeline filter đều là state client-side
- trạng thái orphan/time/timeline trong `GeometryBindingPanel` là derived từ draft + visibility, không phải field persist riêng
+2
View File
@@ -5,6 +5,8 @@ Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đa
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
- `src/uhm/types/projects.ts`
- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
+78 -62
View File
@@ -4,7 +4,7 @@ Mục tiêu của tài liệu này:
- mô tả **chính xác** frontend hiện tại đang dùng gì từ Goong
- mô tả **backend cần proxy gì** để giấu `api_key`
- mô tả **response nào phải rewrite**
- mô tả **response nào phải sanitize/rewrite**
- tránh liệt kê thừa các API Goong mà app hiện tại không đụng tới
Phạm vi kiểm tra:
@@ -22,33 +22,40 @@ Frontend hiện tại **không** `map.setStyle(goongStyleJson)` trực tiếp.
Thay vào đó:
1. app tự `fetch()` 2 style JSON của Goong
1. app tự `fetch()` 2 style JSON của Goong qua backend proxy
2. app parse style JSON để lấy:
- `raster source` từ `goong_satellite.json`
- `sources + layers` cần thiết từ `goong_map_web.json`
3. app `map.addSource(...)``map.addLayer(...)` thủ công
4. từ thời điểm đó, **MapLibre tự request tiếp** các `source.url`
5. rồi từ các source manifest đó, **MapLibre lại tự request tiếp** các tile URLs nằm trong `tiles[]`
3. nếu source dùng `url`, app tiếp tục fetch source manifest qua proxy trong `tiles.ts`
4. app rewrite `tiles[]` về backend proxy rồi `map.addSource(...)``map.addLayer(...)` thủ công
5. từ thời điểm đó, **MapLibre tự request tiếp** tile/font URLs đã là URL proxy
Hệ quả:
- nếu BE chỉ proxy `assets/*.json` thì **chưa đủ**
- nếu BE chỉ proxy `sources/*.json`**không rewrite `tiles[]`** thì **vẫn lộ key ở request tile**
- proxy phải cover style JSON, source manifest, tile URLs và glyph PBF
- frontend hiện không nhúng `api_key` trong URL; backend proxy chịu trách nhiệm gọi upstream bằng key server-side nếu upstream yêu cầu
## 2. Luồng request thật hiện tại
### 2.1. App fetch trực tiếp style JSON
### 2.1. App fetch style JSON qua proxy
Frontend gọi trực tiếp:
Frontend gọi:
1. `https://tiles.goong.io/assets/goong_satellite.json?api_key=...`
2. `https://tiles.goong.io/assets/goong_map_web.json?api_key=...`
1. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_satellite.json`
2. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_map_web.json`
Upstream gốc trong code vẫn là:
1. `https://tiles.goong.io/assets/goong_satellite.json`
2. `https://tiles.goong.io/assets/goong_map_web.json`
Nguồn trong code:
- `GOONG_SATELLITE_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:15)
- `GOONG_VECTOR_OVERLAY_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:19)
- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:211)
- `GOONG_SATELLITE_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:8)
- `GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:9)
- `buildGoongProxyUrl(...)` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:29)
- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:199)
Mục đích:
@@ -63,9 +70,9 @@ Mục đích:
- `Country Labels`
- `Rivers`
### 2.2. MapLibre fetch source manifests
### 2.2. Frontend fetch source manifests qua proxy
Sau khi app clone source spec từ style JSON và `addSource(...)`, MapLibre tự bắn tiếp các request `source.url`.
Khi style source có field `url`, `tiles.ts` tự fetch source manifest qua proxy trước khi gọi `map.addSource(...)`.
Các source URL đang xuất hiện trong style JSON:
@@ -89,21 +96,24 @@ Các source URL đang xuất hiện trong style JSON:
- `sources/goong.json`
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
### 2.3. MapLibre fetch tile URLs nằm trong source manifests
### 2.3. MapLibre fetch tile URLs đã rewrite
Đây là phần dễ bị bỏ sót nhất.
Khi MapLibre đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó sẽ tiếp tục request các URL nằm trong field:
Khi `tiles.ts` đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó rewrite mọi URL trong field:
- `tiles[]`
về `${API_BASE_URL}/proxy/tiles.goong.io/...`, rồi mới đưa source spec cho MapLibre.
Tức là runtime thật của frontend hiện tại là:
1. fetch style JSON
2. fetch source manifest
3. fetch tile URL bên trong source manifest
1. FE fetch style JSON qua proxy
2. FE fetch source manifest qua proxy
3. FE rewrite `tiles[]` về proxy
4. MapLibre fetch tile URL đã rewrite
Nếu backend muốn che key hoàn toàn, thì **bước 3 bắt buộc phải được proxy hoặc rewrite về domain backend**.
Nếu backend muốn che key hoàn toàn, thì backend proxy phải xử lý cả các tile URL này bằng key server-side.
## 3. Những upstream Goong resource đang dùng thật
@@ -130,6 +140,7 @@ Lưu ý:
- tile URL pattern chính xác phải đọc từ source manifest upstream ở runtime
- backend không nên hardcode khi chưa xác minh nội dung `tiles[]`
- frontend hiện giữ nguyên upstream target path trong proxy URL sau khi strip `api_key`
## 4. Những thứ frontend hiện tại dùng thêm hoặc KHÔNG dùng
@@ -143,7 +154,7 @@ Flow hiện tại **có dùng glyphs của Goong qua proxy**.
Map đang trỏ `glyphs` vào:
- `/proxy/{encoded-https://tiles.goong.io/fonts/{fontstack}/{range}.pbf}`
- `${API_BASE_URL}/proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf`
Nguồn trong code:
@@ -201,7 +212,8 @@ Có 2 cách:
#### Cách A: Transparent proxy
BE trả về gần như đúng response của Goong, chỉ rewrite URL.
BE trả về gần như đúng response của Goong, nhưng strip/sanitize mọi `api_key` lồng trong JSON.
Frontend hiện tự wrap các upstream URL đó bằng `buildGoongProxyUrl(...)`.
Ưu điểm:
@@ -210,7 +222,7 @@ BE trả về gần như đúng response của Goong, chỉ rewrite URL.
Nhược điểm:
- BE phải rewrite nhiều chỗ
- BE phải sanitize JSON response để không lộ key trong body response
#### Cách B: Normalize thành API nội bộ
@@ -227,11 +239,13 @@ Nhược điểm:
Với frontend hiện tại, **Cách A** là hợp lý nhất.
Lưu ý quan trọng: frontend hiện mong nhận `sources.*.url``tiles[]` ở dạng upstream URL hoặc relative URL. Không rewrite các URL này thành `/proxy/...` trong JSON response hiện tại, vì FE sẽ tự gọi `buildGoongProxyUrl(...)`; rewrite sẵn sẽ dễ bị double-proxy.
## 6. Contract backend được khuyến nghị
### 6.1. Proxy style JSON
#### `GET /proxy/goong/assets/goong_satellite.json`
#### `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
Upstream:
@@ -241,15 +255,16 @@ Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- rewrite `sources.*.url` về domain backend
- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã rewrite
- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
#### `GET /proxy/goong/assets/goong_map_web.json`
#### `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Upstream:
@@ -259,17 +274,18 @@ Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- rewrite `sources.*.url` về domain backend
- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã rewrite
- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
### 6.2. Proxy source manifests
#### `GET /proxy/goong/sources/satellite.json`
#### `GET /proxy/tiles.goong.io/sources/satellite.json`
Upstream:
@@ -279,7 +295,8 @@ Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata quan trọng:
- `tileSize`
- `minzoom`
@@ -291,9 +308,9 @@ Backend phải:
Response:
- `Content-Type: application/json`
- body: source manifest đã rewrite
- body: source manifest đã sanitize, chưa rewrite sang `/proxy/...`
#### `GET /proxy/goong/sources/base.json`
#### `GET /proxy/tiles.goong.io/sources/base.json`
Upstream:
@@ -303,10 +320,11 @@ Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
#### `GET /proxy/goong/sources/goong.json`
#### `GET /proxy/tiles.goong.io/sources/goong.json`
Upstream:
@@ -316,22 +334,17 @@ Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
### 6.3. Proxy tile endpoints
Backend bắt buộc phải có route để trả tile thật.
Có thể làm generic, ví dụ:
Frontend hiện build URL proxy generic theo upstream target:
- `GET /proxy/goong/tiles/*`
hoặc explicit hơn theo source:
- `GET /proxy/goong/tiles/satellite/...`
- `GET /proxy/goong/tiles/base/...`
- `GET /proxy/goong/tiles/goong/...`
- `GET /proxy/tiles.goong.io/...`
Yêu cầu:
@@ -357,8 +370,9 @@ Luồng:
1. FE đọc `goong_satellite.json`
2. FE lấy `sources.satellite`
3. MapLibre gọi `sources/satellite.json`
4. MapLibre gọi raster tile URLs trong `tiles[]`
3. FE gọi `sources/satellite.json` qua proxy trong `tiles.ts`
4. FE rewrite `tiles[]` về proxy URL
5. MapLibre gọi raster tile URLs đã rewrite
BE cần cover:
@@ -372,9 +386,10 @@ Luồng:
1. FE đọc `goong_map_web.json`
2. FE lấy selected layers + selected sources
3. MapLibre gọi `sources/base.json`
4. MapLibre gọi `sources/goong.json`
5. MapLibre gọi vector tile URLs của 2 source manifest này
3. FE gọi `sources/base.json` qua proxy trong `tiles.ts`
4. FE gọi `sources/goong.json` qua proxy trong `tiles.ts`
5. FE rewrite `tiles[]` về proxy URL
6. MapLibre gọi vector tile URLs đã rewrite
BE cần cover:
@@ -386,20 +401,20 @@ BE cần cover:
Nếu chỉ làm đúng những gì frontend hiện tại dùng, checklist tối thiểu là:
1. proxy `assets/goong_satellite.json`
2. proxy `assets/goong_map_web.json`
3. proxy `sources/satellite.json`
4. proxy `sources/base.json`
5. proxy `sources/goong.json`
6. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
7. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
8. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
1. proxy `tiles.goong.io/assets/goong_satellite.json`
2. proxy `tiles.goong.io/assets/goong_map_web.json`
3. proxy `tiles.goong.io/sources/satellite.json`
4. proxy `tiles.goong.io/sources/base.json`
5. proxy `tiles.goong.io/sources/goong.json`
6. proxy `tiles.goong.io/fonts/{fontstack}/{range}.pbf`
7. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
8. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
9. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
## 9. Những gì BE chưa cần làm ngay
Cho flow hiện tại, BE **chưa cần**:
- proxy Goong `glyphs`
- proxy Goong `sprite`
- proxy geocoding / directions / autocomplete
@@ -417,9 +432,10 @@ vì khi đó chúng có thể trở thành dependency bắt buộc.
Nếu muốn làm ít rủi ro nhất:
1. làm proxy `assets/*.json`
2. rewrite `sources.*.url`
2. sanitize nested `api_key` trong style JSON
3. làm proxy `sources/*.json`
4. rewrite `tiles[]`
4. sanitize nested `api_key` trong source manifests
5. làm proxy generic cho tile
6. làm proxy Goong fonts/glyphs
Nếu làm thiếu bước 4 hoặc 5 thì key vẫn có thể lộ ở request tile.
Nếu sanitize JSON thiếu thì key có thể lộ ngay trong response style/source. Nếu proxy tile/font thiếu thì map background hoặc labels có thể không tải được.
+9 -5
View File
@@ -122,8 +122,12 @@ Những label dễ gây rối nếu bật nhiều:
## Gợi ý mapping cho UI
- `Country Borders` -> `boundary-land-type-0` + `boundary-land-type-0-bg`
- `Province Borders` -> `boundary-land-type-1` + `boundary-land-type-1-bg`
- `District Borders` -> `boundary-land-type-2` + `boundary-land-type-2-bg`
- `Country Labels` -> `place-country-*`, `place-city-capital*`, `place-city*`, `place-town*`
- `Rivers` -> `water`, `water-shadow`, `river-name-*`, `lake-name_*`
Mapping hiện tại trong `tiles.ts` là heuristic runtime, không hardcode đúng từng id này:
- `Country Borders` -> ưu tiên `boundary-land-type-0`, bỏ `boundary-land-type-0-bg`
- `Province Borders` -> ưu tiên `boundary-land-type-1`, bỏ `boundary-land-type-1-bg`
- `District Borders` -> `boundary-land-type-2` và các layer cấp sâu hơn
- `Country Labels` -> symbol layer có text field và tên/source-layer giống country/admin/place/city/town/capital
- `Rivers` -> line/fill layer có tên/source-layer giống water/waterway/river/stream/canal/lake/reservoir/sea/ocean
Water label symbol như `river-name-*`/`lake-name_*` chỉ được đưa vào nếu heuristic sau này mở rộng; code hiện tại chủ yếu lấy line/fill water.
+99 -79
View File
@@ -4,8 +4,8 @@ Tài liệu này mô tả:
- luồng request thật của frontend hiện tại
- backend cần proxy chỗ nào
- backend cần rewrite chỗ nào
- trade-off hiệu suất nếu proxy/rewrite toàn bộ Goong
- backend cần sanitize/rewrite chỗ nào
- trade-off hiệu suất nếu proxy toàn bộ Goong
- khuyến nghị triển khai thực dụng cho team BE
Tài liệu liên quan:
@@ -26,21 +26,23 @@ Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre.
Thay vào đó:
1. FE tự `fetch()` style JSON của Goong
1. FE gọi style JSON qua `buildGoongProxyUrl(...)`
2. FE parse style JSON
3. FE lấy ra:
- raster source cho satellite
- selected vector sources/layers cho borders, labels, rivers
4. FE `addSource()``addLayer()` thủ công
5. MapLibre tự request tiếp `source.url`
6. Từ source manifest, MapLibre tự request tiếp các tile URLs trong `tiles[]`
4. FE gọi source manifest qua `buildGoongProxyUrl(...)` nếu style source có `url`
5. FE rewrite `tiles[]` về proxy URL rồi `addSource()``addLayer()` thủ công
6. MapLibre request tile/font URLs đã là URL proxy
Điểm quan trọng:
- browser có thể không chỉ gọi `assets/*.json`
- browser sẽ đi sâu thêm ít nhất 2 tầng:
- browser không được gọi trực tiếp `tiles.goong.io`
- browser vẫn sẽ đi qua backend proxy ở các tầng:
- `assets/*.json`
- `sources/*.json`
- tile URLs trong `tiles[]`
- `fonts/{fontstack}/{range}.pbf`
## 2. Luồng request hiện tại
@@ -48,20 +50,29 @@ Thay vào đó:
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
participant BE as Backend Proxy
participant GO as Goong
FE->>GO: GET assets/goong_satellite.json?api_key=...
FE->>GO: GET assets/goong_map_web.json?api_key=...
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON with server-side key
GO-->>BE: style JSON
BE-->>FE: sanitized style JSON
FE->>GL: addSource(raster/vector) + addLayer(...)
FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
BE->>GO: fetch upstream source manifests with server-side key
GO-->>BE: source manifests
BE-->>FE: sanitized source manifests
GL->>GO: GET sources/satellite.json?api_key=...
GL->>GO: GET sources/base.json?api_key=...
GL->>GO: GET sources/goong.json?api_key=...
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
GL->>GO: GET raster tile URLs from satellite tiles[]
GL->>GO: GET vector tile URLs from base tiles[]
GL->>GO: GET vector tile URLs from goong tiles[]
GL->>BE: GET /proxy/tiles.goong.io/...tile...
GL->>BE: GET /proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf
BE->>GO: fetch upstream tile/font bytes
GO-->>BE: bytes
BE-->>GL: bytes
```
## 3. Mục tiêu của backend proxy
@@ -75,35 +86,42 @@ thì backend phải đảm bảo:
1. browser chỉ gọi domain BE
2. BE gọi Goong bằng key server-side
3. mọi URL Goong lồng bên trong JSON đều được rewrite về domain BE
3. mọi URL Goong lồng bên trong JSON đều được sanitize để không chứa `api_key`
4. frontend nhận URL upstream/relative sạch để tự wrap qua `buildGoongProxyUrl(...)`
Nếu thiếu bước 3:
- `api_key` vẫn có thể lộ ở request tầng sau
- `api_key` có thể lộ ngay trong response JSON ở browser devtools
## 4. Những gì cần rewrite
## 4. Những gì cần sanitize/rewrite
### 4.1. Style JSON
Trong `goong_satellite.json``goong_map_web.json`, BE cần rewrite:
Trong `goong_satellite.json``goong_map_web.json`, BE cần sanitize:
- `sources.*.url`
- `glyphs`
- `sprite`
Ví dụ:
- từ `https://tiles.goong.io/sources/base.json?api_key=...`
- thành `/proxy/goong/sources/base.json`
- thành `https://tiles.goong.io/sources/base.json`
Không rewrite sẵn thành `/proxy/...` với frontend hiện tại, vì `tiles.ts` đang tự gọi `buildGoongProxyUrl(...)`.
### 4.2. Source manifests
Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần rewrite:
Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần sanitize:
- mọi phần tử trong `tiles[]`
Ví dụ:
- từ `https://.../{z}/{x}/{y}...api_key=...`
- thành `/proxy/goong/tiles/...`
- thành `https://.../{z}/{x}/{y}...`
Sau đó frontend rewrite URL sạch này về `${API_BASE_URL}/proxy/tiles.goong.io/...`.
### 4.3. Những field còn phải để ý cho flow hiện tại
@@ -123,27 +141,28 @@ Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp th
### 5.1. Style endpoints
- `GET /proxy/goong/assets/goong_satellite.json`
- `GET /proxy/goong/assets/goong_map_web.json`
- `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
- `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- rewrite `sources.*.url`
- trả JSON đã rewrite
- strip `api_key` khỏi nested URL
- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...`
### 5.2. Source endpoints
- `GET /proxy/goong/sources/satellite.json`
- `GET /proxy/goong/sources/base.json`
- `GET /proxy/goong/sources/goong.json`
- `GET /proxy/tiles.goong.io/sources/satellite.json`
- `GET /proxy/tiles.goong.io/sources/base.json`
- `GET /proxy/tiles.goong.io/sources/goong.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- rewrite `tiles[]`
- strip `api_key` khỏi `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên:
- `bounds`
- `minzoom`
@@ -154,9 +173,9 @@ Nhiệm vụ:
### 5.3. Tile endpoint
Gợi ý route generic:
Route generic frontend hiện build:
- `GET /proxy/goong/tiles/*`
- `GET /proxy/tiles.goong.io/...`
Nhiệm vụ:
@@ -180,24 +199,25 @@ sequenceDiagram
participant BE as Backend Proxy
participant GO as Goong
FE->>BE: GET /proxy/goong/assets/goong_satellite.json
FE->>BE: GET /proxy/goong/assets/goong_map_web.json
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON
GO-->>BE: style JSON
BE-->>FE: rewritten style JSON
BE-->>FE: sanitized style JSON
FE->>GL: addSource(raster/vector) + addLayer(...)
GL->>BE: GET /proxy/goong/sources/satellite.json
GL->>BE: GET /proxy/goong/sources/base.json
GL->>BE: GET /proxy/goong/sources/goong.json
FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
BE->>GO: fetch upstream source manifests
GO-->>BE: source manifests
BE-->>GL: rewritten source manifests
BE-->>FE: sanitized source manifests
GL->>BE: GET /proxy/goong/tiles/...
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
GL->>BE: GET /proxy/tiles.goong.io/...tile...
GL->>BE: GET /proxy/tiles.goong.io/fonts/...
BE->>GO: fetch upstream tile
GO-->>BE: tile bytes
BE-->>GL: tile bytes
@@ -205,11 +225,11 @@ sequenceDiagram
## 7. Trade-off hiệu suất
### 7.1. Rewrite JSON có chậm không?
### 7.1. Sanitize JSON có chậm không?
Có overhead, nhưng **rất nhỏ** so với tile traffic.
JSON cần rewrite hiện tại chỉ gồm:
JSON cần sanitize hiện tại chỉ gồm:
- 2 style JSON
- 3 source manifests
@@ -218,7 +238,7 @@ Những file này nhỏ, số lượng ít, và có thể cache rất mạnh.
Kết luận:
- rewrite JSON không phải bottleneck chính
- sanitize JSON không phải bottleneck chính
### 7.2. Tile proxy mới là chỗ đắt
@@ -235,21 +255,20 @@ Các ảnh hưởng có thể thấy:
- tăng CPU/memory nếu BE buffer response thay vì stream
- tăng load connection pool tới Goong
### 7.3. Nếu không rewrite tile URL
### 7.3. Nếu không proxy tile/font URL
Nếu BE chỉ rewrite style/source JSON nhưng không rewrite `tiles[]`:
Nếu BE chỉ proxy style/source JSON nhưng thiếu tile/font route:
- browser vẫn gọi Goong trực tiếp ở bước tile
- `api_key` vẫn có thể lộ
- MapLibre request tile/font proxy URL sẽ lỗi
- hoặc nếu FE bị đổi để dùng URL upstream trực tiếp thì browser sẽ gọi Goong và có thể lộ key
Tức là:
- hiệu suất tốt hơn
- nhưng mục tiêu bảo mật key không đạt
- tile/font route vẫn là phần bắt buộc nếu muốn giữ kiến trúc hiện tại
## 8. Cách giảm thiểu impact hiệu suất
### 8.1. Cache rewritten JSON ở BE
### 8.1. Cache sanitized JSON ở BE
Khuyến nghị:
@@ -266,7 +285,7 @@ TTL có thể dài vì:
Tối ưu:
- chỉ rewrite một lần rồi reuse
- chỉ sanitize một lần rồi reuse
### 8.2. Stream tile response
@@ -292,18 +311,18 @@ Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều.
Nếu production có CDN/nginx/edge cache:
- cache mạnh cho:
- rewritten style JSON
- rewritten source manifests
- sanitized style JSON
- sanitized source manifests
- tile responses
Điều này quan trọng hơn tối ưu code rewrite.
Điều này quan trọng hơn tối ưu code sanitize.
### 8.5. Đừng rewrite tile mỗi request theo kiểu string building phức tạp
### 8.5. Đừng parse manifest ở mỗi tile request
Nên:
- rewrite `tiles[]` một lần ở source manifest
- tile route chỉ resolve path đơn giản và forward
- sanitize source manifest một lần rồi cache
- tile route chỉ resolve target path đơn giản và forward
Không nên:
@@ -313,18 +332,19 @@ Không nên:
Nếu team BE muốn giải pháp cân bằng giữa bảo mật và hiệu suất:
### Option A. Full proxy, full rewrite
### Option A. Full proxy, sanitize JSON
BE cover:
1. style JSON
2. source manifests
3. tiles
4. fonts/glyphs
Ưu điểm:
- key không lộ ra browser
- FE không cần biết upstream Goong
- FE vẫn dùng upstream target path sạch rồi tự wrap proxy URL
Nhược điểm:
@@ -337,7 +357,7 @@ BE cover:
1. style JSON
2. source manifests
Nhưng không rewrite `tiles[]`
Nhưng để tile/font đi trực tiếp upstream.
Ưu điểm:
@@ -346,28 +366,27 @@ Nhưng không rewrite `tiles[]`
Nhược điểm:
- key vẫn lộ ở tile request
- không khớp với code hiện tại nếu `buildGoongProxyUrl(...)` vẫn được dùng cho tile/font
Kết luận:
- nếu ưu tiên bảo mật key thật sự: dùng **Option A**
- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: dùng **Option B**
- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: **Option B cần đổi frontend**
## 10. Recommendation cho codebase hiện tại
Với frontend hiện tại, hướng hợp lý nhất là:
1. giữ nguyên FE logic parse style/source như hiện nay
2. chuyển các URL Goong ở `config.ts` sang endpoint nội bộ BE
3. để BE rewrite:
- `sources.*.url`
- `tiles[]`
4. để BE stream tile response
5. cache rewritten JSON ở BE
2. giữ `config.ts` ng upstream URL sạch rồi để `buildGoongProxyUrl(...)` wrap thành `${API_BASE_URL}/proxy/tiles.goong.io/...`
3. để BE sanitize nested `api_key` trong style/source JSON, nhưng không rewrite nested URL thành `/proxy/...`
4. để BE stream tile/font response
5. cache sanitized JSON ở BE
Nói ngắn:
- rewrite JSON: nên làm
- rewrite tile URLs: bắt buộc nếu muốn giấu key
- sanitize JSON: bắt buộc để không lộ key trong response
- FE rewrite tile URLs bằng `buildGoongProxyUrl(...)`
- proxy tile: phần tốn hiệu suất nhất
- muốn bù hiệu suất: phải dùng cache/stream/CDN tốt
@@ -375,10 +394,11 @@ Nói ngắn:
1. Tạo route proxy cho 2 style JSON
2. Tạo route proxy cho 3 source manifests
3. Rewrite `sources.*.url` trong style JSON
4. Rewrite `tiles[]` trong source manifests
3. Strip `api_key` khỏi nested URL trong style JSON
4. Strip `api_key` khỏi `tiles[]` trong source manifests
5. Tạo route proxy tile generic
6. Stream tile response
7. Preserve cache headers
8. Cache rewritten JSON
9. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
6. Tạo route proxy fonts/glyphs
7. Stream tile/font response
8. Preserve cache headers
9. Cache sanitized JSON
10. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
+34 -25
View File
@@ -29,21 +29,23 @@ Nếu map init lỗi, `Map.tsx` render overlay lỗi thay vì crash im lặng.
## 2. Base style và background layers
`getBaseMapStyle()` dựng style MapLibre từ vector tile source `base`.
`getBaseMapStyle()` chỉ dựng skeleton style MapLibre:
Background layers hiện có:
- `glyphs` trỏ vào Goong glyph proxy
- `sources: {}`
- một layer `background` màu nền tối
- `graticules-line`
- `land`
- `bg-countries-fill`
- `bg-country-borders-line`
- `country-labels`
- `regions-line`
- `lakes-fill`
- `rivers-line`
- `geolines-line`
Background thật được thêm sau khi map load:
Visibility của các layer này đi qua `BackgroundLayerVisibility`.
- `raster-base-layer` được lazy-add từ `goong_satellite.json` qua proxy khi visibility bật.
- overlay vector từ `goong_map_web.json` được clone theo nhóm:
- `bg-country-borders-line`
- `bg-province-borders-line`
- `bg-district-borders-line`
- `country-labels`
- `rivers-line`
Visibility của các nhóm này đi qua `BackgroundLayerVisibility`.
## 3. Sources mà editor đang dùng
@@ -85,17 +87,20 @@ Source này dùng cho:
`useMapSync()` chịu trách nhiệm:
1. filter draft theo binding nếu `respectBindingFilter = true`
2. filter theo geometry visibility
3. split feature thành nhóm polygon/line/point
4. decorate line/polygon/point cho label rendering
5. build source riêng cho path arrows
6. set selected feature state
1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước
2. filter draft theo binding nếu `applyGeometryBindingFilter = true`
3. filter theo geometry visibility
4. split feature thành nhóm polygon/line/point
5. decorate line/polygon/point cho label rendering
6. build source riêng cho path arrows
7. set selected feature state
Điểm quan trọng:
- data mà map nhận không phải raw `draft` nguyên xi
- nó là `draft` sau khi đã qua visibility, binding filter và label decoration
- data mà map render không phải raw `mainDraft` nguyên xi
- `renderDraft` là nguồn quyết định geometry nào xuất hiện trên map
- `labelContextDraft` chỉ dùng để lookup label/entity name, có thể chứa geometry đã bị timeline filter ẩn, và không được dùng để quyết định render
- source MapLibre cuối cùng là `renderDraft` sau khi đã qua binding filter, geometry visibility và label decoration
## 5. Map interaction layer
@@ -112,6 +117,8 @@ Binding hiện tại:
`add-point` được init riêng bằng `initPoint`, nhưng hiện chưa được đưa vào `engineBindingsRef` như các mode còn lại; logic create point vẫn được bind trong `setupMapInteractions`.
`replay_preview` không có engine interaction riêng; preview controller điều khiển camera/timeline/visibility qua replay dispatcher.
## 6. Các engine cụ thể
### `initDrawing`
@@ -153,11 +160,12 @@ Binding hiện tại:
- bắt đầu edit geometry
- chuyển sang `replay`
`replay` hiện không phải cinematic replay đầy đủ.
Nó là mode hiển thị tập trung vào một geometry:
Trong map interaction, `replay` vẫn dùng `initSelect`; `replay_preview` không cho edit/select theo engine.
Phần script/preview replay nằm ở sidebar và preview overlay:
- có nút thoát replay
- có thể ẩn geometry ngoài danh sách `binding`
- map render `replayDraft` hydrate từ `target_geometry_ids`
- preview action có thể điều khiển camera, timeline, hidden geometry ids và presentation overlay
- replay mode không cho mutate geometry chính
## 8. Đồng bộ selection và feature state
@@ -194,6 +202,7 @@ Nếu thất bại, map giữ nguyên center mặc định.
## 11. Những điều cần nhớ khi sửa map engine
- preview source/layer và persisted source/layer là hai tầng khác nhau
- `draftRef` được dùng để tránh closure stale trong event handlers
- `renderDraftRef` trong map interaction là dữ liệu đang được render/interact, không phải canonical commit draft
- `draftRef` trong `useEditorState()` vẫn là ref nội bộ của draft để tránh closure stale trong editor state
- `Map` chỉ là orchestration component; logic lớn nằm ở hooks
- geometry render pipeline phụ thuộc khá nhiều vào `mapUtils.ts`, không chỉ mỗi `useMapSync.ts`
+13 -10
View File
@@ -11,7 +11,7 @@ Map hiện có hai nhóm style tách biệt:
### Background/base map
Định nghĩa trong `useMapLayers.ts` qua `getBaseMapStyle()`.
`getBaseMapStyle()` chỉ tạo skeleton style có `background` layer và Goong glyph proxy. Raster/vector background thật được thêm sau khi map load qua `mapUtils.ts``tiles.ts`.
### Geotype style
@@ -22,24 +22,23 @@ Map hiện có hai nhóm style tách biệt:
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
- `raster-base-layer`
- `graticules-line`
- `land`
- `bg-countries-fill`
- `bg-country-borders-line`
- `bg-province-borders-line`
- `bg-district-borders-line`
- `country-labels`
- `regions-line`
- `lakes-fill`
- `rivers-line`
- `geolines-line`
Lưu ý:
- không phải layer nào trong list cũng nhất thiết được add từ cùng một source path trong tương lai
- `raster-base-layer` là layer raster lazy-add từ `goong_satellite.json`
- các nhóm còn lại là overlay layer clone từ `goong_map_web.json`
- overlay layer thật có id dạng `goong-...`, nhưng metadata `uhmBackgroundGroupId` trỏ về toggle id ở trên
- `BackgroundLayersPanel` chỉ biết toggle theo `id`
Visibility mặc định:
- tất cả `true`
- `raster-base-layer`, `bg-country-borders-line`, `country-labels`, `rivers-line` bật
- `bg-province-borders-line`, `bg-district-borders-line` tắt
- được persist bằng `uhm.backgroundLayerVisibility.v1`
## 3. Geotype registry
@@ -77,7 +76,7 @@ Các type đang được register:
- `port`
- `bridge`
`GEOMETRY_TYPE_OPTIONS` trong `geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
`GEOMETRY_TYPE_OPTIONS` trong `src/uhm/lib/map/geo/geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
## 4. Type matching
@@ -119,6 +118,8 @@ Point geotype dùng icon pipeline trong:
- `shared/pointStyle.ts`
- `ensurePointGeotypeIcons(map)`
Icon point hiện chọn theo geotype bình thường. Không còn branch icon/style riêng cho draft-orphan geometry.
Điều này có nghĩa là khi thêm geotype point mới, chỉ thêm layer là chưa đủ; cần chắc icon/style builder cũng hiểu type mới đó.
## 7. Preview và edit styling
@@ -158,6 +159,8 @@ Có ba lớp filter hiển thị trong runtime:
Vì vậy khi một geometry "không hiện", có thể nguyên nhân nằm ở data filtering chứ không phải style layer.
Geometry không bind entity không có màu/icon riêng trên map. Trạng thái orphan/time/timeline nằm trong `GeometryBindingPanel`, còn map chỉ giữ style geotype + selected/focus/edit states.
## 9. Thêm geotype mới - checklist đúng với code hiện tại
Nếu thêm một geotype mới, nên đi theo checklist này:
+11 -3
View File
@@ -60,7 +60,7 @@ Phần nó thật sự quan tâm là:
### Bước 1: load baseline
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
- `initialData` lấy từ `baselineSnapshot.editor_feature_collection`
- `baselineFeatureCollection` lấy từ `baselineSnapshot.editor_feature_collection`
- `useEditorState()` reset draft và undo
### Bước 2: chỉnh sửa cục bộ
@@ -71,6 +71,7 @@ User có thể sửa:
- entity snapshot
- wiki snapshot
- entity-wiki snapshot
- replay script
Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
@@ -80,6 +81,7 @@ Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
- đã mở được project
- `pendingSaveCount > 0`
- không còn orphan geometry
Luồng commit:
@@ -91,7 +93,7 @@ Luồng commit:
- refresh `projectState`
- refresh `sectionCommits`
- cập nhật `baselineSnapshot`
- set `initialData = editor.draft`
- set `baselineFeatureCollection = editor.mainDraft`
- `editor.clearChanges()`
- clear `commitTitle`
@@ -102,6 +104,7 @@ Luồng commit:
- project đang mở
-`head_commit_id`
- `pendingSaveCount === 0`
- không còn orphan geometry
Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submission mới.
@@ -109,6 +112,7 @@ Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submissi
Nút `Restore` trong `CommitHistoryPanel` hiện là restore phía frontend:
- chỉ chạy khi `pendingSaveCount === 0`
- tải commit list mới nhất
- lấy snapshot của commit được chọn
- normalize snapshot
@@ -128,9 +132,10 @@ Nói cách khác, đây là `load snapshot into editor`, không phải `server-s
- `draft`
- `changes`
- `snapshotEntities`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `effectiveReplays`
- `previousSnapshot`
và sinh ra:
@@ -141,12 +146,14 @@ và sinh ra:
- `geometry_entity`
- `wikis`
- `entity_wiki`
- `replays`
Các điểm quan trọng:
- geometry many-to-many với entity được persist ở `geometry_entity[]`
- denormalized fields trên feature như `entity_ids`, `entity_name`, `binding`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API
- wiki/entity/link được chuẩn hóa lại thành `reference`, `binding`, `delete`, `create`, `update` tùy baseline
- replay script được persist ở `replays[]`; `replayDraft` không được gửi
## 7. Dirty state mà user nhìn thấy
@@ -158,6 +165,7 @@ Nó gồm:
- cộng thêm 1 nếu entity dirty
- cộng thêm 1 nếu wiki dirty
- cộng thêm 1 nếu entity-wiki dirty
- cộng thêm 1 nếu replay dirty
Vì vậy:
+3 -1
View File
@@ -55,6 +55,7 @@ Quy ước operation:
- wiki ref thêm từ search: `source: "ref"`, `operation: "reference"`
- wiki đã tồn tại nhưng sửa nội dung: `operation: "update"`
- wiki bị remove khỏi current state: được chuyển thành `delete` khi build snapshot so với baseline
- khi remove wiki, page editor cũng gỡ các link `entity_wiki` trỏ tới wiki đó trong cùng undo group nếu handler ngoài được truyền vào
## 4. Slug
@@ -177,4 +178,5 @@ Hiện tại chưa có:
- schema block editor mới cho project wiki
- cross-project link graph UI
File `doc/commit_snapshot.ts` có chứa schema `replays[]`, nhưng phần replay narrative đó chưa được nối với wiki editor hiện tại.
Replay preview có thể mở `PublicWikiSidebar` bằng action `wiki_panel`, `close_wiki_panel``wiki`.
Wiki editor vẫn không lưu narrative replay trực tiếp; narrative/script nằm trong `replays[]`.
+2 -1
View File
@@ -13,9 +13,10 @@ export type Change = GeometryChange;
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature }
| { type: "delete"; feature: Feature; index?: number }
| { type: "create"; id: FeatureProperties["id"] }
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
| { type: "replays"; label: string; prevReplays: BattleReplay[] }
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
+3 -3
View File
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
export function useDraftState(initialData: FeatureCollection) {
export function useDraftState(seedFeatureCollection: FeatureCollection) {
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(seedFeatureCollection));
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
const draftRef = useRef<FeatureCollection>(deepClone(seedFeatureCollection));
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
+4
View File
@@ -96,6 +96,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
);
}
case "replays": {
const next = b as Extract<UndoAction, { type: "replays" }>;
return a.label === next.label && JSON.stringify(a.prevReplays) === JSON.stringify(next.prevReplays);
}
case "replay_session": {
const next = b as Extract<UndoAction, { type: "replay_session" }>;
return (
@@ -8,7 +8,13 @@ import {
openSectionEditor,
submitSection,
} from "@/uhm/api/projects";
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import {
buildEditorSnapshot,
normalizeEditorSnapshot,
normalizeFeatureEntityIds,
toApiEditorSnapshot,
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -42,15 +48,15 @@ export function useProjectCommands(options: Options) {
// operations should not carry over as deltas into the next commit.
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const commits = await fetchProjectCommits(projectId);
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
state.setActiveSection(editorPayload.project);
state.setSelectedProjectId(editorPayload.project.id);
state.setProjectState(editorPayload.state);
state.setBaselineSnapshot(sessionSnapshot);
state.setInitialData(nextInitialData);
state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
state.setProjectCommits(commits);
state.setSnapshotEntities(sessionSnapshot?.entities || []);
state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
state.setSelectedFeatureIds([]);
@@ -68,6 +74,15 @@ export function useProjectCommands(options: Options) {
return;
}
const orphanGeometries = findOrphanGeometries(options.editor.mainDraft);
if (orphanGeometries.length > 0) {
const firstOrphan = orphanGeometries[0];
state.setSelectedFeatureIds([firstOrphan.id]);
state.setEntityFormStatus("Geometry này chưa bind entity.");
state.setEntityStatus(formatOrphanGeometryMessage("Commit", orphanGeometries));
return;
}
const geometryChanges = options.editor.buildPayload();
state.setIsSaving(true);
state.setEntityStatus(null);
@@ -76,7 +91,7 @@ export function useProjectCommands(options: Options) {
project: state.activeSection,
draft: options.editor.mainDraft,
changes: geometryChanges,
snapshotEntities: state.snapshotEntities,
snapshotEntityRows: state.snapshotEntityRows,
snapshotWikis: state.snapshotWikis,
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
replays: options.editor.effectiveReplays,
@@ -111,10 +126,10 @@ export function useProjectCommands(options: Options) {
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
state.setProjectState(result.state);
state.setBaselineSnapshot(sessionSnapshot);
state.setSnapshotEntities(sessionSnapshot.entities || []);
state.setSnapshotEntityRows(sessionSnapshot.entities || []);
state.setSnapshotWikis(sessionSnapshot.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
state.setInitialData(options.editor.mainDraft);
state.setBaselineFeatureCollection(options.editor.mainDraft);
options.editor.clearChanges();
state.setCommitTitle("");
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
@@ -206,6 +221,15 @@ export function useProjectCommands(options: Options) {
return;
}
const orphanGeometries = findOrphanGeometries(options.editor.mainDraft);
if (orphanGeometries.length > 0) {
const firstOrphan = orphanGeometries[0];
state.setSelectedFeatureIds([firstOrphan.id]);
state.setEntityFormStatus("Geometry này chưa bind entity.");
state.setEntityStatus(formatOrphanGeometryMessage("Submit", orphanGeometries));
return;
}
state.setIsSubmitting(true);
state.setEntityStatus(null);
try {
@@ -220,7 +244,7 @@ export function useProjectCommands(options: Options) {
} finally {
state.setIsSubmitting(false);
}
}, [options.pendingSaveCount, options.store]);
}, [options.editor.mainDraft, options.pendingSaveCount, options.store]);
const restoreCommit = useCallback(async (commitId: string) => {
const state = options.store.getState();
@@ -247,11 +271,11 @@ export function useProjectCommands(options: Options) {
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
state.setBaselineSnapshot(sessionSnapshot);
state.setInitialData(nextInitialData);
state.setSnapshotEntities(sessionSnapshot?.entities || []);
state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
state.setSelectedFeatureIds([]);
@@ -281,6 +305,34 @@ export function useProjectCommands(options: Options) {
};
}
type OrphanGeometry = {
id: Feature["properties"]["id"];
label: string;
};
function findOrphanGeometries(draft: FeatureCollection): OrphanGeometry[] {
const rows: OrphanGeometry[] = [];
for (const feature of draft.features || []) {
const entityIds = normalizeFeatureEntityIds(feature);
if (entityIds.length > 0) continue;
const id = feature.properties.id;
rows.push({
id,
label: String(id),
});
}
return rows;
}
function formatOrphanGeometryMessage(action: "Commit" | "Submit", rows: OrphanGeometry[]): string {
const sample = rows.slice(0, 8).map((row) => row.label).join(", ");
const more = rows.length > 8 ? `, ... (+${rows.length - 8})` : "";
return `Không thể ${action}: còn ${rows.length} geometry chưa bind entity. Hãy bind entity cho: ${sample}${more}.`;
}
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
return {
...snapshot,
@@ -311,8 +363,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
operation: "reference",
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
};
});
}
@@ -333,8 +385,8 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
draw_geometry: g.draw_geometry,
geometry: g.geometry,
binding: Array.isArray(g.binding) ? [...g.binding] : undefined,
time_start: typeof g.time_start === "number" ? g.time_start : g.time_start ?? undefined,
time_end: typeof g.time_end === "number" ? g.time_end : g.time_end ?? undefined,
time_start: normalizeTimelineYearValue(g.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(g.time_end) ?? undefined,
bbox: g.bbox
? {
min_lng: g.bbox.min_lng,
@@ -11,7 +11,7 @@ export function useEntitySessionState() {
// Entity catalog loaded from backend (global list, used for search/lookup).
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
const [snapshotEntityRows, setSnapshotEntityRows] = useState<EntitySnapshot[]>([]);
// Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState<string | null>(null);
// Features đang được chọn để thao tác bind entities/metadata.
@@ -48,8 +48,8 @@ export function useEntitySessionState() {
return {
entityCatalog,
setEntityCatalog,
snapshotEntities,
setSnapshotEntities,
snapshotEntityRows,
setSnapshotEntityRows,
entityStatus,
setEntityStatus,
selectedFeatureIds,
+75 -18
View File
@@ -1,5 +1,6 @@
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
@@ -94,6 +95,11 @@ function getRefId(value: unknown): string {
return typeof value.id === "string" ? value.id : "";
}
function normalizeApiTimeFields(row: UnknownRecord): void {
if ("time_start" in row) row.time_start = normalizeTimelineYearValue(row.time_start);
if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end);
}
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!isRecord(raw)) return null;
const snapshot = raw as UnknownRecord;
@@ -126,8 +132,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
};
})
: undefined;
@@ -156,8 +162,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
geometry: row.geometry as GeometrySnapshot["geometry"],
binding: Array.isArray(row.binding) ? row.binding as string[] : undefined,
time_start: typeof row.time_start === "number" ? row.time_start : row.time_start == null ? undefined : undefined,
time_end: typeof row.time_end === "number" ? row.time_end : row.time_end == null ? undefined : undefined,
time_start: normalizeTimelineYearValue(row.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(row.time_end) ?? undefined,
bbox: isRecord(row.bbox)
? {
min_lng: Number(row.bbox.min_lng),
@@ -278,8 +284,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const name = typeof row.name === "string" ? String(row.name).trim() : "";
if (name) entityNameById.set(id, name);
entityTimeById.set(id, {
time_start: typeof row.time_start === "number" ? row.time_start : null,
time_end: typeof row.time_end === "number" ? row.time_end : null,
time_start: normalizeTimelineYearValue(row.time_start),
time_end: normalizeTimelineYearValue(row.time_end),
});
}
const geometryById = new Map<string, GeometrySnapshot>();
@@ -293,6 +299,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const gid = String(feature.properties.id);
const entity_ids = byGeom.get(gid) || [];
const p = feature.properties as unknown as UnknownRecord;
const existingTimeStart = normalizeTimelineYearValue(p.time_start);
const existingTimeEnd = normalizeTimelineYearValue(p.time_end);
if (existingTimeStart !== null) {
p.time_start = existingTimeStart;
} else {
delete p.time_start;
}
if (existingTimeEnd !== null) {
p.time_end = existingTimeEnd;
} else {
delete p.time_end;
}
const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
@@ -334,8 +352,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|| fallbackTypeKey;
if (typeKey) p.type = typeKey;
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
const timeStart = normalizeTimelineYearValue(geo.time_start);
const timeEnd = normalizeTimelineYearValue(geo.time_end);
if (timeStart !== null) {
p.time_start = timeStart;
} else {
delete p.time_start;
}
if (timeEnd !== null) {
p.time_end = timeEnd;
} else {
delete p.time_end;
}
} else if (!existingTypeKey) {
p.type = fallbackTypeKey;
}
@@ -359,7 +387,7 @@ export function buildEditorSnapshot(options: {
project: Project;
draft: FeatureCollection;
changes: Change[];
snapshotEntities: EntitySnapshot[];
snapshotEntityRows: EntitySnapshot[];
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
replays: BattleReplay[];
@@ -410,11 +438,11 @@ export function buildEditorSnapshot(options: {
operation: "reference",
name: typeof cloned.name === "string" ? cloned.name : undefined,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
});
}
for (const row of options.snapshotEntities || []) {
for (const row of options.snapshotEntityRows || []) {
if (!row) continue;
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
if (!id) continue;
@@ -435,8 +463,8 @@ export function buildEditorSnapshot(options: {
name,
operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
});
}
@@ -483,6 +511,8 @@ export function buildEditorSnapshot(options: {
: "reference";
const bbox = getFeatureBBox(feature);
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
const timeStart = normalizeTimelineYearValue(feature.properties.time_start);
const timeEnd = normalizeTimelineYearValue(feature.properties.time_end);
return {
id,
operation,
@@ -490,8 +520,8 @@ export function buildEditorSnapshot(options: {
type: typeKey,
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
time_end: feature.properties.time_end ?? null,
time_start: timeStart,
time_end: timeEnd,
bbox: bbox
? {
min_lng: bbox.minLng,
@@ -689,8 +719,8 @@ export function buildEditorSnapshot(options: {
operation: e.operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
@@ -713,11 +743,31 @@ export function buildEditorSnapshot(options: {
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot;
if (Array.isArray(cloned.editor_feature_collection?.features)) {
cloned.editor_feature_collection.features = cloned.editor_feature_collection.features.map((feature) => {
const properties = { ...(feature.properties as unknown as UnknownRecord) };
normalizeApiTimeFields(properties);
return {
...feature,
properties: properties as unknown as Feature["properties"],
};
});
}
if (Array.isArray(cloned.entities)) {
cloned.entities = cloned.entities.map((entity) => {
const row = { ...(entity as unknown as UnknownRecord) };
normalizeApiTimeFields(row);
return row as unknown as EntitySnapshot;
});
}
if (Array.isArray(cloned.geometries)) {
cloned.geometries = cloned.geometries.map((geometry) => {
const row = { ...(geometry as unknown as UnknownRecord) };
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
delete row.geo_type;
normalizeApiTimeFields(row);
if (typeKey) {
const typeCode = typeKeyToGeoTypeCode(typeKey);
@@ -846,6 +896,7 @@ function normalizeReplayUiOption(value: unknown): UIOptionName | null {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
@@ -910,6 +961,7 @@ function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null
case "toggle_labels":
case "show_labels":
case "hide_labels":
case "show_all_geometries":
case "reset_camera_north":
return value;
default:
@@ -958,10 +1010,15 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
switch (value) {
case "set_title":
case "clear_title":
case "set_descriptions":
case "clear_descriptions":
case "show_dialog_box":
case "clear_dialog_box":
case "display_historical_image":
case "clear_historical_image":
case "set_step_subtitle":
case "clear_step_subtitle":
return value;
default:
return null;
@@ -24,8 +24,8 @@ type Options = {
export function useEditorSessionState(options: Options) {
// Mode thao tác map/editor hiện tại.
const [mode, setMode] = useState<EditorMode>("idle");
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot).
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
// Baseline FeatureCollection used to seed/reset the editor draft for the current session.
const [baselineFeatureCollection, setBaselineFeatureCollection] = useState<FeatureCollection>(options.emptyFeatureCollection);
const project = useProjectSessionState({
defaultEditorUserId: options.defaultEditorUserId,
@@ -41,8 +41,8 @@ export function useEditorSessionState(options: Options) {
return {
mode,
setMode,
initialData,
setInitialData,
baselineFeatureCollection,
setBaselineFeatureCollection,
...project,
...entity,
...timeline,
+203 -29
View File
@@ -19,8 +19,8 @@ export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
type SnapshotUndoApi = {
snapshotEntitiesRef: { current: EntitySnapshot[] };
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
snapshotEntityRowsRef: { current: EntitySnapshot[] };
setSnapshotEntityRows: Dispatch<SetStateAction<EntitySnapshot[]>>;
snapshotWikisRef: { current: WikiSnapshot[] };
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
@@ -41,7 +41,7 @@ type ReplayDraftSyncMode = "none" | "reset";
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
export function useEditorState(
initialData: FeatureCollection,
baselineFeatureCollection: FeatureCollection,
options: {
snapshotUndo?: SnapshotUndoApi;
initialReplays?: BattleReplay[];
@@ -50,7 +50,7 @@ export function useEditorState(
) {
const { snapshotUndo, initialReplays, mode } = options;
const mainDraftState = useDraftState(initialData);
const mainDraftState = useDraftState(baselineFeatureCollection);
const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
const {
draft: mainDraft,
@@ -116,7 +116,7 @@ export function useEditorState(
// Map baseline (id -> feature) để diff main draft ra changes.
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
buildInitialMap(initialData)
buildInitialMap(baselineFeatureCollection)
);
// Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0);
@@ -132,22 +132,27 @@ export function useEditorState(
targetCommitDraft({
...targetDraftRef.current,
features: targetDraftRef.current.features.filter((feature) =>
feature.properties.id !== action.id
!featureIdEquals(feature.properties.id, action.id)
),
});
return true;
}
case "delete": {
const feature = deepClone(action.feature);
const nextFeatures = [...targetDraftRef.current.features];
const insertAt = typeof action.index === "number" && Number.isFinite(action.index)
? Math.max(0, Math.min(action.index, nextFeatures.length))
: nextFeatures.length;
nextFeatures.splice(insertAt, 0, feature);
targetCommitDraft({
...targetDraftRef.current,
features: [...targetDraftRef.current.features, feature],
features: nextFeatures,
});
return true;
}
case "update": {
const idx = targetDraftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
featureIdEquals(feature.properties.id, action.id)
);
if (idx === -1) return false;
const nextFeatures = [...targetDraftRef.current.features];
@@ -160,7 +165,7 @@ export function useEditorState(
}
case "properties": {
const idx = targetDraftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
featureIdEquals(feature.properties.id, action.id)
);
if (idx === -1) return false;
const nextFeatures = [...targetDraftRef.current.features];
@@ -174,8 +179,8 @@ export function useEditorState(
case "snapshot_entities": {
if (!allowSnapshotUndo || !snapshotUndo) return false;
const prev = deepClone(action.prev);
snapshotUndo.snapshotEntitiesRef.current = prev;
snapshotUndo.setSnapshotEntities(prev);
snapshotUndo.snapshotEntityRowsRef.current = prev;
snapshotUndo.setSnapshotEntityRows(prev);
return true;
}
case "snapshot_wikis": {
@@ -226,6 +231,19 @@ export function useEditorState(
return true;
}
if (action.type === "replays") {
const restoredReplays = deepClone(action.prevReplays || []);
updateReplaysState(restoredReplays);
if (activeReplayId != null) {
const activeReplay = restoredReplays.find((replay) => replay.geometry_id === String(activeReplayId)) || null;
activeReplayOriginRef.current = activeReplay ? deepClone(activeReplay) : null;
activeReplaySeedRef.current = activeReplay ? deepClone(activeReplay) : null;
setActiveReplayDraftState(activeReplay, "reset");
}
return true;
}
return applyUndoActionToDraft(
action,
mainDraftRef,
@@ -264,7 +282,7 @@ export function useEditorState(
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
useEffect(() => {
resetMainDraft(deepClone(initialData));
resetMainDraft(deepClone(baselineFeatureCollection));
resetReplayDraft(EMPTY_FEATURE_COLLECTION);
updateReplaysState(initialReplays || []);
setActiveReplayId(null);
@@ -273,12 +291,12 @@ export function useEditorState(
activeReplaySeedRef.current = null;
clearMainUndo();
clearReplayUndo();
initialMapRef.current = buildInitialMap(initialData);
initialMapRef.current = buildInitialMap(baselineFeatureCollection);
setBaselineVersion((version) => version + 1);
}, [
clearMainUndo,
clearReplayUndo,
initialData,
baselineFeatureCollection,
initialReplays,
resetMainDraft,
resetReplayDraft,
@@ -371,7 +389,7 @@ export function useEditorState(
pushMainUndo({ type: "create", id: featureClone.properties.id });
}
function createFeatureWithSnapshotEntities(
function createFeatureWithSnapshotEntityRows(
feature: Feature,
nextEntities: SetStateAction<EntitySnapshot[]>,
label = "Import geometry"
@@ -384,7 +402,7 @@ export function useEditorState(
const undoActions: UndoAction[] = [];
if (snapshotUndo) {
const prevEntities = snapshotUndo.snapshotEntitiesRef.current || [];
const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || [];
const prevEntitiesClone = deepClone(prevEntities);
const computedEntities = typeof nextEntities === "function"
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
@@ -403,8 +421,8 @@ export function useEditorState(
label: "Cập nhật entities",
prev: prevEntitiesClone,
});
snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone;
snapshotUndo.setSnapshotEntities(computedEntitiesClone);
snapshotUndo.snapshotEntityRowsRef.current = computedEntitiesClone;
snapshotUndo.setSnapshotEntityRows(computedEntitiesClone);
}
}
@@ -428,7 +446,7 @@ export function useEditorState(
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const nextFeatures = [...mainDraftRef.current.features];
@@ -472,7 +490,7 @@ export function useEditorState(
const undoActions: UndoAction[] = [];
for (const [id, patch] of mergedPatches.entries()) {
const idx = nextFeatures.findIndex((feature) => feature.properties.id === id);
const idx = nextFeatures.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) continue;
const prevProperties = deepClone(nextFeatures[idx].properties);
@@ -506,7 +524,7 @@ export function useEditorState(
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const prevFeature = mainDraftRef.current.features[idx];
@@ -529,17 +547,77 @@ export function useEditorState(
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const feature = mainDraftRef.current.features[idx];
const nextFeatures = [...mainDraftRef.current.features];
nextFeatures.splice(idx, 1);
pushMainUndo({ type: "delete", feature: deepClone(feature) });
const undoActions: UndoAction[] = [];
const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`);
if (replayUndoAction) undoActions.push(replayUndoAction);
undoActions.push({ type: "delete", feature: deepClone(feature), index: idx });
pushMainUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label: `Xóa GEO #${feature.properties.id}`, actions: undoActions }
);
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
function deleteFeatures(ids: Array<FeatureProperties["id"]>) {
if (mode === "replay") {
return;
}
const idsSet = new Set(ids.map(String));
const nextFeatures: Feature[] = [];
const undoActions: UndoAction[] = [];
mainDraftRef.current.features.forEach((feature, index) => {
if (idsSet.has(String(feature.properties.id))) {
undoActions.push({ type: "delete", feature: deepClone(feature), index });
} else {
nextFeatures.push(feature);
}
});
if (undoActions.length === 0) return;
const replayUndoAction = pruneReplaysForDeletedGeometryIds(ids, `Xóa replay theo ${undoActions.length} GEO`);
const groupedActions = replayUndoAction
? [replayUndoAction, ...undoActions.slice().reverse()]
: undoActions.length === 1
? undoActions
: undoActions.slice().reverse();
pushMainUndo(
groupedActions.length === 1
? groupedActions[0]
: { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: groupedActions }
);
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
function pruneReplaysForDeletedGeometryIds(
ids: Array<FeatureProperties["id"]>,
label: string
): UndoAction | null {
const deletedIds = new Set(ids.map((id) => String(id)));
if (!deletedIds.size) return null;
const prevReplays = replaysRef.current || [];
const nextReplays = pruneDeletedGeometryIdsFromReplays(prevReplays, deletedIds);
if (replaysEqual(prevReplays, nextReplays)) return null;
updateReplaysState(nextReplays);
return {
type: "replays",
label,
prevReplays: deepClone(prevReplays),
};
}
function buildPayload(): Change[] {
return Array.from(changes.values()).map((change) => deepClone(change));
}
@@ -593,12 +671,12 @@ export function useEditorState(
clearReplayUndo();
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
const setSnapshotEntitiesUndoable = useCallback((
const setSnapshotEntityRowsUndoable = useCallback((
next: SetStateAction<EntitySnapshot[]>,
label = "Cập nhật entities"
) => {
if (!snapshotUndo) return;
const prev = snapshotUndo.snapshotEntitiesRef.current || [];
const prev = snapshotUndo.snapshotEntityRowsRef.current || [];
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
let changed = true;
@@ -611,8 +689,8 @@ export function useEditorState(
const computedClone = deepClone(computed);
pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
snapshotUndo.snapshotEntitiesRef.current = computedClone;
snapshotUndo.setSnapshotEntities(computedClone);
snapshotUndo.snapshotEntityRowsRef.current = computedClone;
snapshotUndo.setSnapshotEntityRows(computedClone);
}, [pushMainUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback((
@@ -661,6 +739,54 @@ export function useEditorState(
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
}, [pushMainUndo, snapshotUndo]);
const setSnapshotWikisAndEntityWikiLinksUndoable = useCallback((
nextWikis: SetStateAction<WikiSnapshot[]>,
nextLinks: SetStateAction<EntityWikiLinkSnapshot[]>,
label = "Cập nhật wiki/entity-wiki"
) => {
if (!snapshotUndo) return;
const prevWikis = snapshotUndo.snapshotWikisRef.current || [];
const prevWikiLinks = snapshotUndo.snapshotEntityWikiLinksRef.current || [];
const prevWikisClone = deepClone(prevWikis);
const prevWikiLinksClone = deepClone(prevWikiLinks);
const computedWikis = typeof nextWikis === "function"
? (nextWikis as (p: WikiSnapshot[]) => WikiSnapshot[])(prevWikisClone)
: nextWikis;
const computedWikiLinks = typeof nextLinks === "function"
? (nextLinks as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevWikiLinksClone)
: nextLinks;
const wikisChanged = !jsonEquals(prevWikis, computedWikis);
const linksChanged = !jsonEquals(prevWikiLinks, computedWikiLinks);
if (!wikisChanged && !linksChanged) return;
const undoActions: Array<Extract<UndoAction, { type: "snapshot_wikis" | "snapshot_entity_wiki" }>> = [];
if (wikisChanged) {
undoActions.push({ type: "snapshot_wikis", label: "Cập nhật wiki", prev: prevWikisClone });
}
if (linksChanged) {
undoActions.push({ type: "snapshot_entity_wiki", label: "Cập nhật entity-wiki", prev: prevWikiLinksClone });
}
pushMainUndo(
undoActions.length === 1
? { ...undoActions[0], label }
: { type: "group", label, actions: undoActions }
);
if (wikisChanged) {
const computedWikisClone = deepClone(computedWikis);
snapshotUndo.snapshotWikisRef.current = computedWikisClone;
snapshotUndo.setSnapshotWikis(computedWikisClone);
}
if (linksChanged) {
const computedWikiLinksClone = deepClone(computedWikiLinks);
snapshotUndo.snapshotEntityWikiLinksRef.current = computedWikiLinksClone;
snapshotUndo.setSnapshotEntityWikiLinks(computedWikiLinksClone);
}
}, [pushMainUndo, snapshotUndo]);
const undo = useCallback(() => {
if (mode === "replay") {
undoReplay();
@@ -690,19 +816,21 @@ export function useEditorState(
changeCount,
canUndoReplay: replayUndoStack.length > 0,
createFeature,
createFeatureWithSnapshotEntities,
createFeatureWithSnapshotEntityRows,
patchFeatureProperties,
patchFeaturePropertiesBatch,
updateFeature,
deleteFeature,
deleteFeatures,
undo,
buildPayload,
clearChanges,
hasPersistedFeature,
// Snapshot undo helpers (no-op if snapshotUndo not provided)
setSnapshotEntities: setSnapshotEntitiesUndoable,
setSnapshotEntityRows: setSnapshotEntityRowsUndoable,
setSnapshotWikis: setSnapshotWikisUndoable,
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable,
};
}
@@ -710,6 +838,18 @@ function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
}
function featureIdEquals(a: FeatureProperties["id"], b: FeatureProperties["id"]) {
return String(a) === String(b);
}
function jsonEquals(a: unknown, b: unknown) {
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
function createReplaySessionSeed(
sourceDraft: FeatureCollection,
geometryId: string,
@@ -860,6 +1000,40 @@ function replaceReplayByGeometryId(
return next;
}
function pruneDeletedGeometryIdsFromReplays(
replays: BattleReplay[],
deletedIds: Set<string>
): BattleReplay[] {
const next: BattleReplay[] = [];
for (const replay of replays || []) {
const geometryId = String(replay?.geometry_id || "");
if (!geometryId || deletedIds.has(geometryId)) continue;
const targetGeometryIds = normalizeReplayTargetGeometryIds(
replay.target_geometry_ids,
geometryId
).filter((id) => !deletedIds.has(id));
next.push({
...deepClone(replay),
id: geometryId,
geometry_id: geometryId,
target_geometry_ids: targetGeometryIds,
});
}
return next;
}
function replaysEqual(a: BattleReplay[] | null | undefined, b: BattleReplay[] | null | undefined) {
try {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
} catch {
return false;
}
}
function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) {
try {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
+115 -48
View File
@@ -5,13 +5,14 @@ import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
export function initSelect(
map: maplibregl.Map,
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onDelete?: (id: string | number | (string | number)[]) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void,
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean
isEditSessionActive?: () => boolean,
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
) {
const FEATURE_STATE_SOURCES = [
@@ -20,7 +21,7 @@ export function initSelect(
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit);
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -43,10 +44,13 @@ export function initSelect(
clearSelection();
}
if (additive && selectedIds.has(id)) {
const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id));
const isAlreadySelected = idToRemove !== undefined;
if (additive && isAlreadySelected) {
// Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false);
selectedIds.delete(id);
setSelectionStateForId(idToRemove, false);
selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds));
return;
}
@@ -97,8 +101,13 @@ export function initSelect(
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
// if right-clicked item not selected, make it the sole selection
if (!selectedIds.has(id)) {
const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id));
const hasSelection = selectedIds.size > 0;
// If the right-clicked item is not selected, and there is no active selection,
// make it the sole selection. If there is an active selection, do not clear it
// so we can bind the active selection to this target geometry.
if (!isRightClickedItemAlreadySelected && !hasSelection) {
clearSelection();
selectFeature(feature, false);
}
@@ -106,7 +115,9 @@ export function initSelect(
showContextMenu(
e.originalEvent?.clientX ?? e.point.x,
e.originalEvent?.clientY ?? e.point.y,
feature
feature,
isRightClickedItemAlreadySelected,
hasSelection
);
}
@@ -161,6 +172,21 @@ export function initSelect(
return 0;
}
// Đồng bộ selection state từ React.
function syncSelection(ids: (string | number)[]) {
const nextSet = new Set(ids);
selectedIds.forEach((id) => {
if (!nextSet.has(id)) {
setSelectionStateForId(id, false);
}
});
selectedIds.clear();
ids.forEach((id) => {
setSelectionStateForId(id, true);
selectedIds.add(id);
});
}
map.on("click", onClick);
map.on("mousemove", onMove);
if (hasContextActions) {
@@ -180,6 +206,7 @@ export function initSelect(
return {
cleanup,
clearSelection,
syncSelection,
};
// Ẩn và dọn dẹp context menu hiện tại.
@@ -198,7 +225,9 @@ export function initSelect(
function showContextMenu(
x: number,
y: number,
clickedFeature: maplibregl.MapGeoJSONFeature
clickedFeature: maplibregl.MapGeoJSONFeature,
isRightClickedItemAlreadySelected: boolean,
hasSelection: boolean
) {
hideContextMenu();
@@ -231,68 +260,106 @@ export function initSelect(
return item;
};
const selectedCount = selectedIds.size || 1;
let hasMenuItems = false;
const selectedCount = selectedIds.size;
const effectiveCount = selectedCount || 1;
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
type MenuItem = {
label: string;
onClick: () => void;
group: "edit" | "bind" | "replay" | "delete";
};
const items: MenuItem[] = [];
if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
const sourceIds = Array.from(selectedIds);
items.push({
group: "bind",
label: `Bind ${selectedCount} geo đang chọn vào geo này`,
onClick: () => {
onBindGeometries(targetId, sourceIds);
},
});
}
if (!isClickOutsideSelection) {
if (
selectedCount === 1 &&
effectiveCount === 1 &&
clickedFeature.source === "countries" &&
clickedFeature.geometry?.type === "Polygon" &&
onEdit
) {
const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
hasMenuItems = true;
items.push({
group: "edit",
label: "Chỉnh sửa",
onClick: () => onEdit(single),
});
}
if (selectedCount === 1 && onDuplicate) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
hasMenuItems = true;
}
if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
items.push({
group: "edit",
label: "Duplicate",
onClick: () => onDuplicate(targetId),
});
}
if (selectedCount === 1 && onHide) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Hide", () => onHide(featureId)));
hasMenuItems = true;
if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
items.push({
group: "edit",
label: "Hide",
onClick: () => onHide(targetId),
});
}
}
if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(
createItem(
selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay",
() => onReplayEdit(featureId)
)
);
hasMenuItems = true;
const replayId = targetId;
if (replayId !== undefined && replayId !== null) {
const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount;
items.push({
group: "replay",
label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay",
onClick: () => onReplayEdit(replayId),
});
}
}
if (onDelete) {
menu.appendChild(
createItem(
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
() => {
items.push({
group: "delete",
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
onClick: () => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [clickedFeature.id ?? clickedFeature.properties?.id];
ids.forEach((eachId) => {
if (eachId !== undefined && eachId !== null) onDelete(eachId);
});
clearSelection();
: [targetId];
if (ids.length === 1) {
onDelete(ids[0]);
} else {
onDelete(ids);
}
)
);
hasMenuItems = true;
clearSelection();
},
});
}
if (!hasMenuItems) return;
if (items.length === 0) return;
let lastGroup: string | null = null;
items.forEach((item) => {
if (lastGroup !== null && lastGroup !== item.group) {
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.background = "#374151";
separator.style.margin = "4px 0";
menu.appendChild(separator);
}
menu.appendChild(createItem(item.label, item.onClick));
lastGroup = item.group;
});
document.body.appendChild(menu);
contextMenu = menu;
+9 -20
View File
@@ -45,7 +45,6 @@ type PointStyleConfig = {
};
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const ICON_CANVAS_SIZE = 48;
@@ -168,7 +167,7 @@ export function buildPointGeotypeLayers(
source: pointSourceId,
filter: pointFilter(typeId),
layout: {
"icon-image": pointIconExpression(typeId),
"icon-image": getPointIconId(typeId),
"icon-size": [
"interpolate",
["linear"],
@@ -233,9 +232,8 @@ function preloadPointIcons() {
function updateIconsOnMap(map: maplibregl.Map, typeId: PointGeotypeId) {
if (!map || !map.getStyle()) return;
try {
for (const variant of ["default", "draft"] as const) {
const iconId = getPointIconId(typeId, variant);
const imageData = createPointIconImageData(typeId, variant);
const iconId = getPointIconId(typeId);
const imageData = createPointIconImageData(typeId);
if (imageData) {
if (map.hasImage(iconId)) {
map.updateImage(iconId, imageData);
@@ -243,7 +241,6 @@ function updateIconsOnMap(map: maplibregl.Map, typeId: PointGeotypeId) {
map.addImage(iconId, imageData, { pixelRatio: 2 });
}
}
}
} catch (err) {
console.warn(`Failed to update icon ${typeId} on map:`, err);
}
@@ -262,14 +259,12 @@ export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean {
}
for (const typeId of POINT_GEOTYPE_IDS) {
for (const variant of ["default", "draft"] as const) {
const iconId = getPointIconId(typeId, variant);
const iconId = getPointIconId(typeId);
if (map.hasImage(iconId)) continue;
const imageData = createPointIconImageData(typeId, variant);
const imageData = createPointIconImageData(typeId);
if (!imageData) return false;
map.addImage(iconId, imageData, { pixelRatio: 2 });
}
}
return true;
}
@@ -278,19 +273,13 @@ function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification
return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
}
function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")];
function getPointIconId(typeId: PointGeotypeId): string {
return `point-${typeId}`;
}
function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string {
return `point-${typeId}-${variant}`;
}
function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null {
function createPointIconImageData(typeId: PointGeotypeId): ImageData | null {
const config = POINT_STYLE_CONFIG[typeId];
const palette = variant === "draft"
? { fill: DRAFT_FILL, rim: DRAFT_RIM }
: { fill: config.fill, rim: config.rim };
const palette = { fill: config.fill, rim: config.rim };
const canvas = document.createElement("canvas");
canvas.width = ICON_CANVAS_SIZE;
+2 -19
View File
@@ -1,17 +1,9 @@
import maplibregl, { LayerSpecification } from "maplibre-gl";
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = [
"all",
["==", ["coalesce", ["get", "entity_id"], ""], ""],
["!", ["has", "binding"]]
];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const SELECTED_COLOR = "#22c55e";
const SELECTED_STROKE = "#14532d";
const DRAFT_COLOR = "#ef4444";
const DRAFT_STROKE = "#7f1d1d";
type ZoomStops = {
z1: number;
@@ -177,8 +169,6 @@ function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
"case",
SELECTED_EXPR,
SELECTED_COLOR,
DRAFT_ENTITY_EXPR,
DRAFT_COLOR,
normalColor,
];
}
@@ -188,19 +178,12 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
"case",
SELECTED_EXPR,
SELECTED_COLOR,
DRAFT_ENTITY_EXPR,
DRAFT_STROKE,
normalColor,
];
}
function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
return [
"case",
DRAFT_ENTITY_EXPR,
DRAFT_COLOR,
normalColor,
];
function statusFillColor(normalColor: string): string {
return normalColor;
}
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
+15
View File
@@ -23,3 +23,18 @@ export function clampYearValue(year: number, minYear: number, maxYear: number):
export function clampYearToFixedRange(year: number): number {
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
}
export function normalizeTimelineYearValue(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? Math.trunc(value) : null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.length) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
return null;
}
+10 -9
View File
@@ -35,9 +35,9 @@ export type GeometryFocusRequest = {
};
type EditorStoreValues = {
// Editor mode + draft seed.
// Editor mode + baseline FeatureCollection used to seed/reset useEditorState.
mode: EditorMode;
initialData: FeatureCollection;
baselineFeatureCollection: FeatureCollection;
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
isSaving: boolean;
isSubmitting: boolean;
@@ -54,7 +54,7 @@ type EditorStoreValues = {
baselineSnapshot: EditorSnapshot | null;
// Entity state: backend catalog plus snapshot-local rows and form/search status.
entityCatalog: Entity[];
snapshotEntities: EntitySnapshot[];
snapshotEntityRows: EntitySnapshot[];
entityStatus: string | null;
selectedFeatureIds: FeatureId[];
entityForm: EntityFormState;
@@ -92,12 +92,13 @@ type EditorStoreValues = {
geometryFocusRequest: GeometryFocusRequest | null;
replayFeatureId: string | number | null;
hideOutside: boolean;
// Map visibility overrides keyed by either a geometry id or a semantic geo type key.
geometryVisibility: Record<string, boolean>;
};
type EditorStoreActions = {
setMode: (next: SetStateAction<EditorMode>) => void;
setInitialData: (next: SetStateAction<FeatureCollection>) => void;
setBaselineFeatureCollection: (next: SetStateAction<FeatureCollection>) => void;
setIsSaving: (next: SetStateAction<boolean>) => void;
setIsSubmitting: (next: SetStateAction<boolean>) => void;
setIsOpeningSection: (next: SetStateAction<boolean>) => void;
@@ -111,7 +112,7 @@ type EditorStoreActions = {
setProjectCommits: (next: SetStateAction<ProjectCommit[]>) => void;
setBaselineSnapshot: (next: SetStateAction<EditorSnapshot | null>) => void;
setEntityCatalog: (next: SetStateAction<Entity[]>) => void;
setSnapshotEntities: (next: SetStateAction<EntitySnapshot[]>) => void;
setSnapshotEntityRows: (next: SetStateAction<EntitySnapshot[]>) => void;
setEntityStatus: (next: SetStateAction<string | null>) => void;
setSelectedFeatureIds: (next: SetStateAction<FeatureId[]>) => void;
setEntityForm: (next: SetStateAction<EntityFormState>) => void;
@@ -228,7 +229,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
return {
mode: "idle",
initialData: options.emptyFeatureCollection,
baselineFeatureCollection: options.emptyFeatureCollection,
isSaving: false,
isSubmitting: false,
isOpeningSection: false,
@@ -242,7 +243,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
sectionCommits: [],
baselineSnapshot: null,
entityCatalog: [],
snapshotEntities: [],
snapshotEntityRows: [],
entityStatus: null,
selectedFeatureIds: [],
entityForm: {
@@ -287,7 +288,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
hideOutside: false,
geometryVisibility: buildInitialGeometryVisibility(),
setMode: (next) => setValue("mode", next),
setInitialData: (next) => setValue("initialData", next),
setBaselineFeatureCollection: (next) => setValue("baselineFeatureCollection", next),
setIsSaving: (next) => setTaskFlag("saving", next),
setIsSubmitting: (next) => setTaskFlag("submitting", next),
setIsOpeningSection: (next) => setTaskFlag("opening-project", next),
@@ -301,7 +302,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
setProjectCommits: (next) => setValue("sectionCommits", next),
setBaselineSnapshot: (next) => setValue("baselineSnapshot", next),
setEntityCatalog: (next) => setValue("entityCatalog", next),
setSnapshotEntities: (next) => setValue("snapshotEntities", next),
setSnapshotEntityRows: (next) => setValue("snapshotEntityRows", next),
setEntityStatus: (next) => setValue("entityStatus", next),
setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next),
setEntityForm: (next) => setValue("entityForm", next),