Compare commits
6 Commits
dc6d048645
...
82dfd7fa56
| Author | SHA1 | Date | |
|---|---|---|---|
| 82dfd7fa56 | |||
| 051835b9bd | |||
| 282b365287 | |||
| 3b4ff71b9a | |||
| ee468fe4fe | |||
| 8f0e912d9e |
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||
</label>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</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>
|
||||
<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"
|
||||
@@ -125,7 +136,78 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<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,111 +224,113 @@ 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>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Loại GEO
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||
<option value={geometryMetaForm.type_key}>
|
||||
Custom Type ({geometryMetaForm.type_key})
|
||||
</option>
|
||||
) : null}
|
||||
{groupedGeoTypeOptions.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
|
||||
{!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>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||
<option value={geometryMetaForm.type_key}>
|
||||
Custom Type ({geometryMetaForm.type_key})
|
||||
</option>
|
||||
) : null}
|
||||
{groupedGeoTypeOptions.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selectedTypeOption ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||
</div>
|
||||
) : geometryMetaForm.type_key ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
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
|
||||
</button>
|
||||
{onReplayEdit && selectedFeatures.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||
style={{
|
||||
...primaryGeometryButtonStyle,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
Replay Edit
|
||||
</button>
|
||||
</select>
|
||||
{selectedTypeOption ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||
</div>
|
||||
) : geometryMetaForm.type_key ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyGeoMeta}
|
||||
disabled={isEntitySubmitting}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
||||
</button>
|
||||
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||
style={{
|
||||
...primaryGeometryButtonStyle,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
Replay Edit
|
||||
</button>
|
||||
)}
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
@@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({
|
||||
Thay đổi sẽ vào lịch sử khi Commit.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||
if (onRemoveWiki) {
|
||||
onRemoveWiki(id);
|
||||
} else {
|
||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||
}
|
||||
if (activeId === id) setActiveId(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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` và `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` và `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
|
||||
|
||||
@@ -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` và `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` và `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.
|
||||
@@ -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` và `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` và `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.
|
||||
@@ -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`.
|
||||
@@ -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()` và `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.
|
||||
@@ -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`
|
||||
- là `BattleReplay` đang chỉnh
|
||||
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
|
||||
- chỉ chứa `id`, `geometry_id`, `target_geometry_ids`, `detail`
|
||||
- `replayDraft`
|
||||
- là `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ở.
|
||||
|
||||
@@ -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()`
|
||||
- Là `FeatureCollection` đang được nạp vào editor khi mở project hoặc restore commit
|
||||
- Là `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()`
|
||||
- Là nguồn dữ liệu render trực tiếp cho `Map`
|
||||
- Là 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`
|
||||
|
||||
- là `draft` đã qua filter timeline nếu `timelineFilterEnabled = true`
|
||||
- là `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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(...)` và `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(...)` và `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` mà **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` và `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()` và `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()` và `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` và `goong_map_web.json`, BE cần rewrite:
|
||||
Trong `goong_satellite.json` và `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` dù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
@@ -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
@@ -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` và `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:
|
||||
|
||||
@@ -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ở
|
||||
- có `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:
|
||||
|
||||
|
||||
@@ -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` và `wiki`.
|
||||
Wiki editor vẫn không lưu narrative replay trực tiếp; narrative/script nằm trong `replays[]`.
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (
|
||||
selectedCount === 1 &&
|
||||
clickedFeature.source === "countries" &&
|
||||
clickedFeature.geometry?.type === "Polygon" &&
|
||||
onEdit
|
||||
) {
|
||||
const single = clickedFeature;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
hasMenuItems = true;
|
||||
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 (selectedCount === 1 && onDuplicate) {
|
||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||
if (featureId !== undefined && featureId !== null) {
|
||||
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
|
||||
hasMenuItems = true;
|
||||
if (!isClickOutsideSelection) {
|
||||
if (
|
||||
effectiveCount === 1 &&
|
||||
clickedFeature.source === "countries" &&
|
||||
clickedFeature.geometry?.type === "Polygon" &&
|
||||
onEdit
|
||||
) {
|
||||
const single = clickedFeature;
|
||||
items.push({
|
||||
group: "edit",
|
||||
label: "Chỉnh sửa",
|
||||
onClick: () => onEdit(single),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 && onDuplicate && targetId !== undefined && targetId !== null) {
|
||||
items.push({
|
||||
group: "edit",
|
||||
label: "Duplicate",
|
||||
onClick: () => onDuplicate(targetId),
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
items.push({
|
||||
group: "delete",
|
||||
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
|
||||
onClick: () => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [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;
|
||||
|
||||
@@ -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,15 +232,13 @@ 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);
|
||||
if (imageData) {
|
||||
if (map.hasImage(iconId)) {
|
||||
map.updateImage(iconId, imageData);
|
||||
} else {
|
||||
map.addImage(iconId, imageData, { pixelRatio: 2 });
|
||||
}
|
||||
const iconId = getPointIconId(typeId);
|
||||
const imageData = createPointIconImageData(typeId);
|
||||
if (imageData) {
|
||||
if (map.hasImage(iconId)) {
|
||||
map.updateImage(iconId, imageData);
|
||||
} else {
|
||||
map.addImage(iconId, imageData, { pixelRatio: 2 });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -262,13 +259,11 @@ 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);
|
||||
if (map.hasImage(iconId)) continue;
|
||||
const imageData = createPointIconImageData(typeId, variant);
|
||||
if (!imageData) return false;
|
||||
map.addImage(iconId, imageData, { pixelRatio: 2 });
|
||||
}
|
||||
const iconId = getPointIconId(typeId);
|
||||
if (map.hasImage(iconId)) continue;
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user