refactor: undo feature cover every single part of editor
This commit is contained in:
@@ -3,6 +3,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
|
|||||||
import type { Feature, Geometry } from "@/uhm/types/geo";
|
import type { Feature, Geometry } from "@/uhm/types/geo";
|
||||||
import type { BattleReplay } from "@/uhm/types/projects";
|
import type { BattleReplay } from "@/uhm/types/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
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ỡ.
|
// 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 {
|
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.
|
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
|
||||||
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
|
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
|
||||||
const start = feature.properties.time_start;
|
const start = normalizeTimelineYearValue(feature.properties.time_start);
|
||||||
const end = feature.properties.time_end;
|
const end = normalizeTimelineYearValue(feature.properties.time_end);
|
||||||
if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
|
if (start !== null && year < start) return false;
|
||||||
if (typeof end === "number" && Number.isFinite(end) && year > end) return false;
|
if (end !== null && year > end) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +58,8 @@ export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | und
|
|||||||
source: e.source,
|
source: e.source,
|
||||||
name: typeof e.name === "string" ? e.name.trim() : "",
|
name: typeof e.name === "string" ? e.name.trim() : "",
|
||||||
description: e.description == null ? null : String(e.description),
|
description: e.description == null ? null : String(e.description),
|
||||||
time_start: typeof e.time_start === "number" ? e.time_start : null,
|
time_start: normalizeTimelineYearValue(e.time_start),
|
||||||
time_end: typeof e.time_end === "number" ? e.time_end : null,
|
time_end: normalizeTimelineYearValue(e.time_end),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.id.localeCompare(b.id));
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-89
@@ -52,7 +52,7 @@ import {
|
|||||||
scaleImageOverlayCoordinatesByFactor,
|
scaleImageOverlayCoordinatesByFactor,
|
||||||
type MapImageOverlay,
|
type MapImageOverlay,
|
||||||
} from "@/uhm/components/map/imageOverlay";
|
} 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 { useFeatureCommands } from "./featureCommands";
|
||||||
import { deleteSubmission } from "@/uhm/api/projects";
|
import { deleteSubmission } from "@/uhm/api/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
@@ -126,7 +126,7 @@ function EditorPageContent() {
|
|||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
internalSetMode,
|
internalSetMode,
|
||||||
initialData,
|
baselineFeatureCollection,
|
||||||
isSaving,
|
isSaving,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
isOpeningSection,
|
isOpeningSection,
|
||||||
@@ -139,8 +139,8 @@ function EditorPageContent() {
|
|||||||
baselineSnapshot,
|
baselineSnapshot,
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
setEntityCatalog,
|
setEntityCatalog,
|
||||||
snapshotEntities,
|
snapshotEntityRows,
|
||||||
setSnapshotEntities,
|
setSnapshotEntityRows,
|
||||||
entityStatus,
|
entityStatus,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
@@ -203,7 +203,7 @@ function EditorPageContent() {
|
|||||||
} = useEditorStore(useShallow((state) => ({
|
} = useEditorStore(useShallow((state) => ({
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
internalSetMode: state.setMode,
|
internalSetMode: state.setMode,
|
||||||
initialData: state.initialData,
|
baselineFeatureCollection: state.baselineFeatureCollection,
|
||||||
isSaving: state.isSaving,
|
isSaving: state.isSaving,
|
||||||
isSubmitting: state.isSubmitting,
|
isSubmitting: state.isSubmitting,
|
||||||
isOpeningSection: state.isOpeningSection,
|
isOpeningSection: state.isOpeningSection,
|
||||||
@@ -216,8 +216,8 @@ function EditorPageContent() {
|
|||||||
baselineSnapshot: state.baselineSnapshot,
|
baselineSnapshot: state.baselineSnapshot,
|
||||||
entityCatalog: state.entityCatalog,
|
entityCatalog: state.entityCatalog,
|
||||||
setEntityCatalog: state.setEntityCatalog,
|
setEntityCatalog: state.setEntityCatalog,
|
||||||
snapshotEntities: state.snapshotEntities,
|
snapshotEntityRows: state.snapshotEntityRows,
|
||||||
setSnapshotEntities: state.setSnapshotEntities,
|
setSnapshotEntityRows: state.setSnapshotEntityRows,
|
||||||
entityStatus: state.entityStatus,
|
entityStatus: state.entityStatus,
|
||||||
setEntityStatus: state.setEntityStatus,
|
setEntityStatus: state.setEntityStatus,
|
||||||
selectedFeatureIds: state.selectedFeatureIds,
|
selectedFeatureIds: state.selectedFeatureIds,
|
||||||
@@ -284,12 +284,12 @@ function EditorPageContent() {
|
|||||||
const geoSearchRequestRef = useRef(0);
|
const geoSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
// Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất.
|
// 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 snapshotWikisRef = useRef(snapshotWikis);
|
||||||
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
snapshotEntitiesRef.current = snapshotEntities;
|
snapshotEntityRowsRef.current = snapshotEntityRows;
|
||||||
}, [snapshotEntities]);
|
}, [snapshotEntityRows]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
snapshotWikisRef.current = snapshotWikis;
|
snapshotWikisRef.current = snapshotWikis;
|
||||||
}, [snapshotWikis]);
|
}, [snapshotWikis]);
|
||||||
@@ -298,10 +298,10 @@ function EditorPageContent() {
|
|||||||
}, [snapshotEntityWikiLinks]);
|
}, [snapshotEntityWikiLinks]);
|
||||||
|
|
||||||
// Hook quản lý draft/changes/undo cho main editor và replay editor.
|
// Hook quản lý draft/changes/undo cho main editor và replay editor.
|
||||||
const editor = useEditorState(initialData, {
|
const editor = useEditorState(baselineFeatureCollection, {
|
||||||
snapshotUndo: {
|
snapshotUndo: {
|
||||||
snapshotEntitiesRef,
|
snapshotEntityRowsRef,
|
||||||
setSnapshotEntities,
|
setSnapshotEntityRows,
|
||||||
snapshotWikisRef,
|
snapshotWikisRef,
|
||||||
setSnapshotWikis,
|
setSnapshotWikis,
|
||||||
snapshotEntityWikiLinksRef,
|
snapshotEntityWikiLinksRef,
|
||||||
@@ -324,26 +324,39 @@ function EditorPageContent() {
|
|||||||
},
|
},
|
||||||
[editor]
|
[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.
|
// Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung.
|
||||||
const snapshotEntitiesAsEntities = useMemo(() => {
|
const snapshotEntityRowsAsEntities = useMemo(() => {
|
||||||
const rows = snapshotEntities || [];
|
const rows = snapshotEntityRows || [];
|
||||||
return rows
|
return rows
|
||||||
.filter((e) => e && e.operation !== "delete")
|
.filter((e) => e && e.operation !== "delete")
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
id: String(e.id || ""),
|
id: String(e.id || ""),
|
||||||
name: String(e.name || "").trim() || String(e.id || ""),
|
name: String(e.name || "").trim() || String(e.id || ""),
|
||||||
description: e.description ?? null,
|
description: e.description ?? null,
|
||||||
time_start: e.time_start ?? null,
|
time_start: normalizeTimelineYearValue(e.time_start),
|
||||||
time_end: e.time_end ?? null,
|
time_end: normalizeTimelineYearValue(e.time_end),
|
||||||
geometry_count: 0,
|
geometry_count: 0,
|
||||||
}))
|
}))
|
||||||
.filter((e) => e.id.length > 0 && e.name.length > 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.
|
// Entity list hợp nhất giữa backend catalog và snapshot local.
|
||||||
const entities = useMemo(
|
const entities = useMemo(
|
||||||
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
() => mergeEntitySearchResults(entityCatalog, snapshotEntityRowsAsEntities),
|
||||||
[entityCatalog, snapshotEntitiesAsEntities]
|
[entityCatalog, snapshotEntityRowsAsEntities]
|
||||||
);
|
);
|
||||||
// State vị trí stage/step đang chọn trong replay editor.
|
// State vị trí stage/step đang chọn trong replay editor.
|
||||||
const [replaySelection, setReplaySelection] = useState<{
|
const [replaySelection, setReplaySelection] = useState<{
|
||||||
@@ -450,7 +463,7 @@ function EditorPageContent() {
|
|||||||
const localCreatedIds = localCreatedEntityIdsRef.current;
|
const localCreatedIds = localCreatedEntityIdsRef.current;
|
||||||
if (!localCreatedIds.size) return;
|
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) => {
|
setEntityCatalog((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = (prev || []).filter((entity) => {
|
const next = (prev || []).filter((entity) => {
|
||||||
@@ -465,7 +478,7 @@ function EditorPageContent() {
|
|||||||
});
|
});
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [snapshotEntities, setEntityCatalog]);
|
}, [snapshotEntityRows, setEntityCatalog]);
|
||||||
|
|
||||||
// Clamp năm timeline vào range cố định trước khi đưa vào store.
|
// Clamp năm timeline vào range cố định trước khi đưa vào store.
|
||||||
const handleTimelineYearChange = useCallback((nextYear: number) => {
|
const handleTimelineYearChange = useCallback((nextYear: number) => {
|
||||||
@@ -484,33 +497,45 @@ function EditorPageContent() {
|
|||||||
selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex,
|
selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex,
|
||||||
onSelectStep: () => {},
|
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.
|
// Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay.
|
||||||
const replayPreviewDraft = useMemo(() => {
|
const replayPreviewDraft = useMemo(() => {
|
||||||
const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION;
|
const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION;
|
||||||
if (!isReplayPreviewMode || replayPreview.hiddenGeometryIds.length === 0) {
|
if (!isReplayPreviewMode || replayPreviewHiddenGeometryIds.length === 0) {
|
||||||
return sourceDraft;
|
return sourceDraft;
|
||||||
}
|
}
|
||||||
const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
|
const hiddenIds = new Set(replayPreviewHiddenGeometryIds);
|
||||||
return {
|
return {
|
||||||
...sourceDraft,
|
...sourceDraft,
|
||||||
features: sourceDraft.features.filter(
|
features: sourceDraft.features.filter(
|
||||||
(feature) => !hiddenIds.has(String(feature.properties.id))
|
(feature) => !hiddenIds.has(String(feature.properties.id))
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]);
|
}, [isReplayPreviewMode, previewSession?.draft, replayPreviewHiddenGeometryIds]);
|
||||||
|
|
||||||
const activeTimelineYear = isReplayPreviewMode
|
const activeTimelineYear = isReplayPreviewMode
|
||||||
? replayPreview.timelineYear
|
? replayPreviewTimelineYear
|
||||||
: timelineDraftYear;
|
: timelineDraftYear;
|
||||||
const activeTimelineFilterEnabled = isReplayPreviewMode
|
const activeTimelineFilterEnabled = isReplayPreviewMode
|
||||||
? replayPreview.timelineFilterEnabled
|
? replayPreviewTimelineFilterEnabled
|
||||||
: timelineFilterEnabled;
|
: 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.
|
// 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 mapRenderDraft = useMemo(() => {
|
||||||
const timelineVisibleDraft = useMemo(() => {
|
|
||||||
const activeDraft = isReplayPreviewMode
|
const activeDraft = isReplayPreviewMode
|
||||||
? replayPreviewDraft
|
? replayPreviewDraft
|
||||||
: isReplayEditMode
|
: isReplayEditMode
|
||||||
@@ -555,8 +580,8 @@ function EditorPageContent() {
|
|||||||
const selectedGeometryTime = useMemo(() => {
|
const selectedGeometryTime = useMemo(() => {
|
||||||
if (!selectedFeature) return null;
|
if (!selectedFeature) return null;
|
||||||
return {
|
return {
|
||||||
time_start: selectedFeature.properties.time_start ?? null,
|
time_start: normalizeTimelineYearValue(selectedFeature.properties.time_start),
|
||||||
time_end: selectedFeature.properties.time_end ?? null,
|
time_end: normalizeTimelineYearValue(selectedFeature.properties.time_end),
|
||||||
};
|
};
|
||||||
}, [selectedFeature]);
|
}, [selectedFeature]);
|
||||||
|
|
||||||
@@ -566,8 +591,8 @@ function EditorPageContent() {
|
|||||||
for (const [id, change] of editor.changes.entries()) {
|
for (const [id, change] of editor.changes.entries()) {
|
||||||
if (change.action === "create") createdGeometryIds.add(String(id));
|
if (change.action === "create") createdGeometryIds.add(String(id));
|
||||||
}
|
}
|
||||||
const timelineVisibleGeometryIds = new Set(
|
const mapRenderGeometryIds = new Set(
|
||||||
timelineVisibleDraft.features.map((feature) => String(feature.properties.id))
|
mapRenderDraft.features.map((feature) => String(feature.properties.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = (editor.draft.features || [])
|
const rows = (editor.draft.features || [])
|
||||||
@@ -575,19 +600,38 @@ function EditorPageContent() {
|
|||||||
.map((f) => {
|
.map((f) => {
|
||||||
const id = String(f.properties.id);
|
const id = String(f.properties.id);
|
||||||
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
time_start: f.properties.time_start ?? null,
|
time_start: timeStart,
|
||||||
time_end: f.properties.time_end ?? null,
|
time_end: timeEnd,
|
||||||
isTimelineVisible: timelineVisibleGeometryIds.has(id),
|
isTimelineVisible,
|
||||||
|
isOrphan: normalizeFeatureEntityIds(f).length === 0,
|
||||||
|
timeStatus,
|
||||||
|
timelineStatus,
|
||||||
isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id),
|
isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
rows.sort((a, b) => a.id.localeCompare(b.id));
|
rows.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return rows;
|
return rows;
|
||||||
}, [editor, timelineVisibleDraft.features]);
|
}, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]);
|
||||||
|
|
||||||
// Binding ids của geometry đại diện đang chọn.
|
// Binding ids của geometry đại diện đang chọn.
|
||||||
const selectedGeometryBindingIds = useMemo(() => {
|
const selectedGeometryBindingIds = useMemo(() => {
|
||||||
@@ -620,13 +664,13 @@ function EditorPageContent() {
|
|||||||
// Dirty flag cho entity snapshot so với baseline commit.
|
// Dirty flag cho entity snapshot so với baseline commit.
|
||||||
const entitiesDirty = useMemo(() => {
|
const entitiesDirty = useMemo(() => {
|
||||||
const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities);
|
const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities);
|
||||||
const next = normalizeEntitiesForCompare(snapshotEntities);
|
const next = normalizeEntitiesForCompare(snapshotEntityRows);
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(prev) !== JSON.stringify(next);
|
return JSON.stringify(prev) !== JSON.stringify(next);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}, [baselineSnapshot?.entities, snapshotEntities]);
|
}, [baselineSnapshot?.entities, snapshotEntityRows]);
|
||||||
|
|
||||||
// Dirty flag cho binding entity-wiki so với baseline commit.
|
// Dirty flag cho binding entity-wiki so với baseline commit.
|
||||||
const entityWikiDirty = useMemo(() => {
|
const entityWikiDirty = useMemo(() => {
|
||||||
@@ -679,11 +723,11 @@ function EditorPageContent() {
|
|||||||
|
|
||||||
// Thoát preview và quay về replay edit mode.
|
// Thoát preview và quay về replay edit mode.
|
||||||
const exitReplayPreview = useCallback(() => {
|
const exitReplayPreview = useCallback(() => {
|
||||||
replayPreview.resetPreview();
|
resetReplayPreview();
|
||||||
setPreviewAutoplayMode(null);
|
setPreviewAutoplayMode(null);
|
||||||
setPreviewSession(null);
|
setPreviewSession(null);
|
||||||
internalSetMode("replay");
|
internalSetMode("replay");
|
||||||
}, [internalSetMode, replayPreview.resetPreview]);
|
}, [internalSetMode, resetReplayPreview]);
|
||||||
|
|
||||||
// Đóng băng draft/replay hiện tại thành session preview để phát thử.
|
// Đóng băng draft/replay hiện tại thành session preview để phát thử.
|
||||||
const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => {
|
const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => {
|
||||||
@@ -722,7 +766,7 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "replay_preview") {
|
if (mode === "replay_preview") {
|
||||||
replayPreview.resetPreview();
|
resetReplayPreview();
|
||||||
setPreviewAutoplayMode(null);
|
setPreviewAutoplayMode(null);
|
||||||
setPreviewSession(null);
|
setPreviewSession(null);
|
||||||
|
|
||||||
@@ -763,7 +807,7 @@ function EditorPageContent() {
|
|||||||
editor,
|
editor,
|
||||||
internalSetMode,
|
internalSetMode,
|
||||||
mode,
|
mode,
|
||||||
replayPreview.resetPreview,
|
resetReplayPreview,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
setHideOutside,
|
setHideOutside,
|
||||||
setReplayFeatureId,
|
setReplayFeatureId,
|
||||||
@@ -811,17 +855,17 @@ function EditorPageContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return;
|
if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return;
|
||||||
if (previewAutoplayMode === "selection") {
|
if (previewAutoplayMode === "selection") {
|
||||||
replayPreview.playFromSelection();
|
playReplayPreviewFromSelection();
|
||||||
} else {
|
} else {
|
||||||
replayPreview.playFromStart();
|
playReplayPreviewFromStart();
|
||||||
}
|
}
|
||||||
setPreviewAutoplayMode(null);
|
setPreviewAutoplayMode(null);
|
||||||
}, [
|
}, [
|
||||||
isReplayPreviewMode,
|
isReplayPreviewMode,
|
||||||
|
playReplayPreviewFromSelection,
|
||||||
|
playReplayPreviewFromStart,
|
||||||
previewAutoplayMode,
|
previewAutoplayMode,
|
||||||
previewSession,
|
previewSession,
|
||||||
replayPreview.playFromSelection,
|
|
||||||
replayPreview.playFromStart,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -833,29 +877,32 @@ function EditorPageContent() {
|
|||||||
// Label ngắn cho overlay preview tại step đang phát.
|
// Label ngắn cho overlay preview tại step đang phát.
|
||||||
const replayPreviewActiveStepLabel = useMemo(() => {
|
const replayPreviewActiveStepLabel = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
replayPreview.activeCursor.stageId == null ||
|
replayPreviewActiveCursor.stageId == null ||
|
||||||
replayPreview.activeCursor.stepIndex == null
|
replayPreviewActiveCursor.stepIndex == null
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
|
return `Stage #${replayPreviewActiveCursor.stageId} · Step ${replayPreviewActiveCursor.stepIndex + 1}`;
|
||||||
}, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
|
}, [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ở.
|
// Wiki snapshot đang được step preview yêu cầu mở.
|
||||||
const replayPreviewActiveWikiSnapshot = useMemo(() => {
|
const replayPreviewActiveWikiSnapshot = useMemo(() => {
|
||||||
if (!replayPreview.activeWikiId) return null;
|
if (!replayPreviewActiveWikiId) return null;
|
||||||
return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null;
|
return replayPreviewWikiRows.find((item) => item.id === replayPreviewActiveWikiId) || null;
|
||||||
}, [replayPreview.activeWikiId, replayPreviewWikiRows]);
|
}, [replayPreviewActiveWikiId, replayPreviewWikiRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReplayPreviewMode || !replayPreview.sidebarOpen) {
|
if (!isReplayPreviewMode || !replayPreviewSidebarOpen) {
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setIsPreviewWikiLoading(false);
|
setIsPreviewWikiLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeWikiId = String(replayPreview.activeWikiId || "").trim();
|
const activeWikiId = String(replayPreviewActiveWikiId || "").trim();
|
||||||
if (!activeWikiId.length) {
|
if (!activeWikiId.length) {
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setIsPreviewWikiLoading(false);
|
setIsPreviewWikiLoading(false);
|
||||||
@@ -905,8 +952,8 @@ function EditorPageContent() {
|
|||||||
}, [
|
}, [
|
||||||
isReplayPreviewMode,
|
isReplayPreviewMode,
|
||||||
previewWikiCache,
|
previewWikiCache,
|
||||||
replayPreview.activeWikiId,
|
replayPreviewActiveWikiId,
|
||||||
replayPreview.sidebarOpen,
|
replayPreviewSidebarOpen,
|
||||||
replayPreviewWikiRows,
|
replayPreviewWikiRows,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -936,8 +983,8 @@ function EditorPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
replayPreview.openWikiPanelById(match.id);
|
openReplayPreviewWikiPanelById(match.id);
|
||||||
}, [replayPreview.openWikiPanelById, replayPreviewWikiRows]);
|
}, [openReplayPreviewWikiPanelById, replayPreviewWikiRows]);
|
||||||
|
|
||||||
// Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview.
|
// Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview.
|
||||||
const effectiveGeometryVisibility = useMemo(() => {
|
const effectiveGeometryVisibility = useMemo(() => {
|
||||||
@@ -1259,12 +1306,12 @@ function EditorPageContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
const stillExistIds = selectedFeatureIds.filter(id =>
|
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) {
|
if (stillExistIds.length !== selectedFeatureIds.length) {
|
||||||
setSelectedFeatureIds(stillExistIds);
|
setSelectedFeatureIds(stillExistIds);
|
||||||
}
|
}
|
||||||
}, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]);
|
}, [editor.draft.features, selectedFeatureIds, setSelectedFeatureIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
@@ -1285,15 +1332,13 @@ function EditorPageContent() {
|
|||||||
? selectedFeature.properties.type
|
? selectedFeature.properties.type
|
||||||
: getDefaultTypeIdForFeature(selectedFeature);
|
: getDefaultTypeIdForFeature(selectedFeature);
|
||||||
const currentId = String(selectedFeature.properties.id);
|
const currentId = String(selectedFeature.properties.id);
|
||||||
|
const timeStart = normalizeTimelineYearValue(selectedFeature.properties.time_start);
|
||||||
|
const timeEnd = normalizeTimelineYearValue(selectedFeature.properties.time_end);
|
||||||
setSelectedGeometryEntityIds(featureEntityIds);
|
setSelectedGeometryEntityIds(featureEntityIds);
|
||||||
setGeometryMetaForm({
|
setGeometryMetaForm({
|
||||||
type_key: nextTypeKey,
|
type_key: nextTypeKey,
|
||||||
time_start: selectedFeature.properties.time_start != null
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
? String(selectedFeature.properties.time_start)
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
: "",
|
|
||||||
time_end: selectedFeature.properties.time_end != null
|
|
||||||
? String(selectedFeature.properties.time_end)
|
|
||||||
: "",
|
|
||||||
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||||
});
|
});
|
||||||
// Only clear status when switching to a different geometry, not when patching metadata/bindings
|
// Only clear status when switching to a different geometry, not when patching metadata/bindings
|
||||||
@@ -1348,7 +1393,7 @@ function EditorPageContent() {
|
|||||||
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
||||||
const id = String(entity.id || "").trim();
|
const id = String(entity.id || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
editor.setSnapshotEntities((prev) => {
|
editor.setSnapshotEntityRows((prev) => {
|
||||||
if (prev.some((e) => String(e.id) === id)) return prev;
|
if (prev.some((e) => String(e.id) === id)) return prev;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1357,8 +1402,8 @@ function EditorPageContent() {
|
|||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
description: entity.description ?? null,
|
description: entity.description ?? null,
|
||||||
time_start: entity.time_start ?? null,
|
time_start: normalizeTimelineYearValue(entity.time_start),
|
||||||
time_end: entity.time_end ?? null,
|
time_end: normalizeTimelineYearValue(entity.time_end),
|
||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
@@ -1399,7 +1444,7 @@ function EditorPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.setSnapshotEntities((prev) => prev.map((e) => {
|
editor.setSnapshotEntityRows((prev) => prev.map((e) => {
|
||||||
if (!e || String(e.id) !== id) return e;
|
if (!e || String(e.id) !== id) return e;
|
||||||
const source = e.source === "inline" ? "inline" : "ref";
|
const source = e.source === "inline" ? "inline" : "ref";
|
||||||
const operation =
|
const operation =
|
||||||
@@ -1583,8 +1628,8 @@ function EditorPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const geoTimeStart = feature.properties.time_start;
|
const geoTimeStart = normalizeTimelineYearValue(feature.properties.time_start);
|
||||||
if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) {
|
if (geoTimeStart !== null) {
|
||||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart)));
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1756,8 +1801,8 @@ function EditorPageContent() {
|
|||||||
properties: {
|
properties: {
|
||||||
id: geoId,
|
id: geoId,
|
||||||
type: typeKey,
|
type: typeKey,
|
||||||
time_start: typeof geo.time_start === "number" ? geo.time_start : null,
|
time_start: normalizeTimelineYearValue(geo.time_start),
|
||||||
time_end: typeof geo.time_end === "number" ? geo.time_end : null,
|
time_end: normalizeTimelineYearValue(geo.time_end),
|
||||||
binding: bindingIds.length ? bindingIds : undefined,
|
binding: bindingIds.length ? bindingIds : undefined,
|
||||||
entity_id: entityItem.entity_id,
|
entity_id: entityItem.entity_id,
|
||||||
entity_ids: [entityItem.entity_id],
|
entity_ids: [entityItem.entity_id],
|
||||||
@@ -1767,7 +1812,7 @@ function EditorPageContent() {
|
|||||||
geometry,
|
geometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.createFeatureWithSnapshotEntities(
|
editor.createFeatureWithSnapshotEntityRows(
|
||||||
feature,
|
feature,
|
||||||
(prev) => {
|
(prev) => {
|
||||||
if (prev.some((e) => String(e.id) === importedEntity.id)) return prev;
|
if (prev.some((e) => String(e.id) === importedEntity.id)) return prev;
|
||||||
@@ -1859,7 +1904,7 @@ function EditorPageContent() {
|
|||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
try {
|
try {
|
||||||
editor.setSnapshotEntities((prev) => {
|
editor.setSnapshotEntityRows((prev) => {
|
||||||
if (prev.some((e) => String(e.id) === entityId)) return prev;
|
if (prev.some((e) => String(e.id) === entityId)) return prev;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1910,13 +1955,15 @@ function EditorPageContent() {
|
|||||||
setSelectedFeatureIds([feature.properties.id]);
|
setSelectedFeatureIds([feature.properties.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng.
|
// Base draft for label lookup only. It must not decide which geometry is rendered.
|
||||||
const mapLabelSourceDraft = isReplayPreviewMode
|
const labelContextBaseDraft = isReplayPreviewMode
|
||||||
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
|
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
|
||||||
: editor.draft;
|
: editor.draft;
|
||||||
|
// Enriched label context may contain geometries that mapRenderDraft filtered out.
|
||||||
|
// Map rendering must still use mapRenderDraft above.
|
||||||
const mapLabelContextDraft = useMemo(
|
const mapLabelContextDraft = useMemo(
|
||||||
() => buildEntityLabelContextDraft(mapLabelSourceDraft, entities),
|
() => buildEntityLabelContextDraft(labelContextBaseDraft, entities),
|
||||||
[entities, mapLabelSourceDraft]
|
[entities, labelContextBaseDraft]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2033,7 +2080,7 @@ function EditorPageContent() {
|
|||||||
ref={mapHandleRef}
|
ref={mapHandleRef}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onSetMode={setMode}
|
onSetMode={setMode}
|
||||||
draft={timelineVisibleDraft}
|
renderDraft={mapRenderDraft}
|
||||||
labelContextDraft={mapLabelContextDraft}
|
labelContextDraft={mapLabelContextDraft}
|
||||||
labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null}
|
labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null}
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
@@ -2050,7 +2097,7 @@ function EditorPageContent() {
|
|||||||
onUpdateFeature={editor.updateFeature}
|
onUpdateFeature={editor.updateFeature}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
geometryVisibility={effectiveGeometryVisibility}
|
geometryVisibility={effectiveGeometryVisibility}
|
||||||
respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled}
|
applyGeometryBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled}
|
||||||
highlightFeatures={null}
|
highlightFeatures={null}
|
||||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||||
@@ -2193,6 +2240,7 @@ function EditorPageContent() {
|
|||||||
<WikiSidebarPanel
|
<WikiSidebarPanel
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
setWikis={setSnapshotWikisUndoable}
|
setWikis={setSnapshotWikisUndoable}
|
||||||
|
onRemoveWiki={removeSnapshotWikiUndoable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EntityWikiBindingsPanel
|
<EntityWikiBindingsPanel
|
||||||
@@ -2287,8 +2335,8 @@ function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
time_start: entity?.time_start ?? null,
|
time_start: normalizeTimelineYearValue(entity?.time_start),
|
||||||
time_end: entity?.time_end ?? null,
|
time_end: normalizeTimelineYearValue(entity?.time_end),
|
||||||
};
|
};
|
||||||
}).filter((candidate) => candidate !== null);
|
}).filter((candidate) => candidate !== null);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+7
-12
@@ -47,6 +47,8 @@ type LinkEntityPopupState = {
|
|||||||
left: number;
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CachedWiki = Wiki & { __fetched?: boolean };
|
||||||
|
|
||||||
const EMPTY_RELATIONS: RelationIndex = {
|
const EMPTY_RELATIONS: RelationIndex = {
|
||||||
entitiesById: {},
|
entitiesById: {},
|
||||||
entityGeometriesById: {},
|
entityGeometriesById: {},
|
||||||
@@ -84,7 +86,7 @@ export default function Page() {
|
|||||||
const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false);
|
const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false);
|
||||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
const [activeWikiSlug, setActiveWikiSlug] = 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 [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||||
@@ -123,13 +125,6 @@ export default function Page() {
|
|||||||
const hoverPopupHoveredRef = useRef(false);
|
const hoverPopupHoveredRef = useRef(false);
|
||||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
const stillExistIds = selectedFeatureIds.filter(id =>
|
const stillExistIds = selectedFeatureIds.filter(id =>
|
||||||
@@ -416,7 +411,7 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, [linkEntityPopup]);
|
}, [linkEntityPopup]);
|
||||||
|
|
||||||
const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined;
|
const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWikiSlug) {
|
if (!activeWikiSlug) {
|
||||||
@@ -459,7 +454,7 @@ export default function Page() {
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
setWikiCache((prev) => ({
|
setWikiCache((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true } as any,
|
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setWikiCache((prev) => ({
|
setWikiCache((prev) => ({
|
||||||
@@ -525,7 +520,7 @@ export default function Page() {
|
|||||||
{isBackgroundVisibilityReady ? (
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
mode="select"
|
mode="select"
|
||||||
draft={data}
|
renderDraft={data}
|
||||||
labelContextDraft={mapLabelContextDraft}
|
labelContextDraft={mapLabelContextDraft}
|
||||||
labelTimelineYear={timelineDraftYear}
|
labelTimelineYear={timelineDraftYear}
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
@@ -533,7 +528,7 @@ export default function Page() {
|
|||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
geometryVisibility={geometryVisibility}
|
geometryVisibility={geometryVisibility}
|
||||||
allowGeometryEditing={false}
|
allowGeometryEditing={false}
|
||||||
respectBindingFilter={true}
|
applyGeometryBindingFilter={true}
|
||||||
onHoverFeatureChange={handleMapHoverChange}
|
onHoverFeatureChange={handleMapHoverChange}
|
||||||
highlightFeatures={activeEntityGeometries}
|
highlightFeatures={activeEntityGeometries}
|
||||||
focusFeatureCollection={activeEntityGeometries}
|
focusFeatureCollection={activeEntityGeometries}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useModal } from "@/hooks/useModal";
|
import { useModal } from "@/hooks/useModal";
|
||||||
@@ -11,19 +10,39 @@ import { Modal } from "@/components/ui/modal";
|
|||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/components/ui/button/Button";
|
||||||
import Label from "@/components/form/Label";
|
import Label from "@/components/form/Label";
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
import { CreateProjectPayload, Project, ProjectMember } from "@/interface/project";
|
||||||
import {
|
import {
|
||||||
apiCreateProject,
|
apiCreateProject,
|
||||||
apiCreateProjectCommit,
|
apiCreateProjectCommit,
|
||||||
apiGetProjectCommits,
|
apiGetProjectCommits,
|
||||||
getCurrentProject,
|
getCurrentProject,
|
||||||
} from "@/service/projectService";
|
} from "@/service/projectService";
|
||||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
import type { EditorSnapshot, ProjectCommit } from "@/uhm/types/projects";
|
||||||
import StickyHeader from "@/components/ui/StickyHeader";
|
import StickyHeader from "@/components/ui/StickyHeader";
|
||||||
|
|
||||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
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() {
|
export default function ProjectsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
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
|
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
|
||||||
if (importSnapshot) {
|
if (importSnapshot) {
|
||||||
|
const snapshot = toApiEditorSnapshot(importSnapshot);
|
||||||
await apiCreateProjectCommit(projectId, {
|
await apiCreateProjectCommit(projectId, {
|
||||||
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
||||||
snapshot_json: importSnapshot as any,
|
snapshot_json: snapshot,
|
||||||
} as any);
|
});
|
||||||
toast.success("Tạo dự án từ JSON thành công!");
|
toast.success("Tạo dự án từ JSON thành công!");
|
||||||
} else {
|
} else {
|
||||||
toast.success("Tạo dự án mới thành công!");
|
toast.success("Tạo dự án mới thành công!");
|
||||||
@@ -138,11 +158,10 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
setIsExportingProjectId(projectId);
|
setIsExportingProjectId(projectId);
|
||||||
try {
|
try {
|
||||||
const res: any = await apiGetProjectCommits(projectId);
|
const res = await apiGetProjectCommits(projectId);
|
||||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
const commits = extractProjectCommitList(res);
|
||||||
const commits = Array.isArray(rawList) ? rawList : [];
|
|
||||||
const head =
|
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;
|
const snapshot = head?.snapshot_json ?? null;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
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) => {
|
const sortedProjects = [...projects].sort((a, b) => {
|
||||||
let valA = a[sortBy];
|
const valA = String(a[sortBy] || "");
|
||||||
let valB = b[sortBy];
|
const valB = String(b[sortBy] || "");
|
||||||
|
|
||||||
if (!valA) valA = "";
|
|
||||||
if (!valB) valB = "";
|
|
||||||
|
|
||||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
{sortedProjects.map((project: any) => (
|
{sortedProjects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
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
|
{project.members
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((m: any, index: number) =>
|
.map((m: ProjectMember, index: number) =>
|
||||||
m.avatar_url ? (
|
m.avatar_url ? (
|
||||||
<Image
|
<Image
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,9 +16,9 @@ export interface Project {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
};
|
};
|
||||||
commits?: any[];
|
commits?: unknown[];
|
||||||
// Legacy (old BE): submission_ids
|
// Legacy (old BE): submission_ids
|
||||||
submission_ids?: any[];
|
submission_ids?: string[];
|
||||||
// New BE: lightweight submissions list on project response
|
// New BE: lightweight submissions list on project response
|
||||||
submissions?: Array<{ id: string; status: string }>;
|
submissions?: Array<{ id: string; status: string }>;
|
||||||
members?: ProjectMember[];
|
members?: ProjectMember[];
|
||||||
@@ -63,7 +65,7 @@ export interface GetProjectsParams {
|
|||||||
}
|
}
|
||||||
export interface CreateCommitPayload {
|
export interface CreateCommitPayload {
|
||||||
edit_summary: string;
|
edit_summary: string;
|
||||||
snapshot_json: number[];
|
snapshot_json: EditorSnapshot;
|
||||||
}
|
}
|
||||||
export interface RestoreCommitPayload {
|
export interface RestoreCommitPayload {
|
||||||
commit_id: string;
|
commit_id: string;
|
||||||
|
|||||||
+15
-12
@@ -33,12 +33,15 @@ export type MapHandle = {
|
|||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
mode: EditorMode;
|
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;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
geometryVisibility?: Record<string, boolean>;
|
geometryVisibility?: Record<string, boolean>;
|
||||||
selectedFeatureIds: (string | number)[];
|
selectedFeatureIds: (string | number)[];
|
||||||
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
||||||
onSetMode?: (mode: EditorMode, featureId?: 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;
|
labelContextDraft?: FeatureCollection;
|
||||||
labelTimelineYear?: number | null;
|
labelTimelineYear?: number | null;
|
||||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||||
@@ -46,7 +49,7 @@ type MapProps = {
|
|||||||
onHideFeature?: (id: string | number) => void;
|
onHideFeature?: (id: string | number) => void;
|
||||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||||
allowGeometryEditing?: boolean;
|
allowGeometryEditing?: boolean;
|
||||||
respectBindingFilter?: boolean;
|
applyGeometryBindingFilter?: boolean;
|
||||||
height?: CSSProperties["height"];
|
height?: CSSProperties["height"];
|
||||||
fitToDraftBounds?: boolean;
|
fitToDraftBounds?: boolean;
|
||||||
fitBoundsKey?: string | number | null;
|
fitBoundsKey?: string | number | null;
|
||||||
@@ -63,7 +66,7 @@ type MapProps = {
|
|||||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||||
mode,
|
mode,
|
||||||
onSetMode,
|
onSetMode,
|
||||||
draft,
|
renderDraft,
|
||||||
backgroundVisibility,
|
backgroundVisibility,
|
||||||
geometryVisibility,
|
geometryVisibility,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
@@ -75,7 +78,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
onHideFeature,
|
onHideFeature,
|
||||||
onUpdateFeature,
|
onUpdateFeature,
|
||||||
allowGeometryEditing = true,
|
allowGeometryEditing = true,
|
||||||
respectBindingFilter = true,
|
applyGeometryBindingFilter = true,
|
||||||
height = "100vh",
|
height = "100vh",
|
||||||
fitToDraftBounds = false,
|
fitToDraftBounds = false,
|
||||||
fitBoundsKey = null,
|
fitBoundsKey = null,
|
||||||
@@ -90,8 +93,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
}, ref) {
|
}, ref) {
|
||||||
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
|
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
|
||||||
// Ref callback select feature mới nhất cho event click trên map.
|
// Ref callback select feature mới nhất cho event click trên map.
|
||||||
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
||||||
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
|
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
|
||||||
@@ -114,7 +117,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
|
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
|
||||||
|
|
||||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
|
||||||
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
|
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
|
||||||
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
|
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
|
||||||
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
|
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
|
||||||
@@ -159,7 +162,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
mapRef,
|
mapRef,
|
||||||
mode,
|
mode,
|
||||||
modeRef,
|
modeRef,
|
||||||
draftRef,
|
renderDraftRef,
|
||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
onSelectFeatureIdsRef,
|
onSelectFeatureIdsRef,
|
||||||
@@ -174,19 +177,19 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
|
|
||||||
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
|
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
|
||||||
const {
|
const {
|
||||||
applyDraftToMap,
|
applyRenderDraftToMap,
|
||||||
applyHighlightToMap,
|
applyHighlightToMap,
|
||||||
applyImageOverlayToMap,
|
applyImageOverlayToMap,
|
||||||
tryCenterToUserLocation,
|
tryCenterToUserLocation,
|
||||||
} = useMapSync({
|
} = useMapSync({
|
||||||
mapRef,
|
mapRef,
|
||||||
draft,
|
renderDraft,
|
||||||
labelContextDraft,
|
labelContextDraft,
|
||||||
labelTimelineYear,
|
labelTimelineYear,
|
||||||
backgroundVisibility,
|
backgroundVisibility,
|
||||||
geometryVisibility,
|
geometryVisibility,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
respectBindingFilter,
|
applyGeometryBindingFilter,
|
||||||
fitToDraftBounds,
|
fitToDraftBounds,
|
||||||
fitBoundsKey,
|
fitBoundsKey,
|
||||||
highlightFeatures,
|
highlightFeatures,
|
||||||
@@ -206,7 +209,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
|
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
|
||||||
applyImageOverlayToMap();
|
applyImageOverlayToMap();
|
||||||
setupMapInteractions(map);
|
setupMapInteractions(map);
|
||||||
applyDraftToMap(draftRef.current);
|
applyRenderDraftToMap(renderDraftRef.current);
|
||||||
tryCenterToUserLocation();
|
tryCenterToUserLocation();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ function wikiTitle(w: WikiSnapshot): string {
|
|||||||
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||||
const {
|
const {
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
snapshotEntities,
|
snapshotEntityRows,
|
||||||
wikis,
|
wikis,
|
||||||
links,
|
links,
|
||||||
} = useEditorStore(
|
} = useEditorStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
entityCatalog: state.entityCatalog,
|
entityCatalog: state.entityCatalog,
|
||||||
snapshotEntities: state.snapshotEntities,
|
snapshotEntityRows: state.snapshotEntityRows,
|
||||||
wikis: state.snapshotWikis,
|
wikis: state.snapshotWikis,
|
||||||
links: state.snapshotEntityWikiLinks,
|
links: state.snapshotEntityWikiLinks,
|
||||||
}))
|
}))
|
||||||
@@ -59,18 +59,18 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const entityChoices = useMemo<EntityChoice[]>(() => {
|
const entityChoices = useMemo<EntityChoice[]>(() => {
|
||||||
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||||
for (const ref of snapshotEntities || []) {
|
for (const ref of snapshotEntityRows || []) {
|
||||||
const id = String(ref?.id || "").trim();
|
const id = String(ref?.id || "").trim();
|
||||||
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
|
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
|
||||||
visibleSnapshotEntities.set(id, {
|
visibleSnapshotEntityRows.set(id, {
|
||||||
id,
|
id,
|
||||||
name: String(ref?.name || id),
|
name: String(ref?.name || id),
|
||||||
isNew: ref?.source === "inline" && ref?.operation === "create",
|
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;
|
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
@@ -80,7 +80,7 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
|||||||
});
|
});
|
||||||
rows.sort((a, b) => a.name.localeCompare(b.name));
|
rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return rows;
|
return rows;
|
||||||
}, [entityCatalog, snapshotEntities]);
|
}, [entityCatalog, snapshotEntityRows]);
|
||||||
|
|
||||||
const activeLinks = useMemo(() => {
|
const activeLinks = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
|
|||||||
@@ -3,17 +3,29 @@
|
|||||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type GeometryChoice = {
|
type GeometryChoice = {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
time_start?: number | null;
|
time_start?: unknown;
|
||||||
time_end?: number | null;
|
time_end?: unknown;
|
||||||
isTimelineVisible?: boolean;
|
isTimelineVisible?: boolean;
|
||||||
|
isOrphan?: boolean;
|
||||||
|
timeStatus?: GeometryTimeStatus;
|
||||||
|
timelineStatus?: GeometryTimelineStatus;
|
||||||
isNew?: boolean;
|
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 = {
|
type Props = {
|
||||||
geometries: GeometryChoice[];
|
geometries: GeometryChoice[];
|
||||||
selectedGeometryId?: string | null;
|
selectedGeometryId?: string | null;
|
||||||
@@ -61,9 +73,12 @@ export default function GeometryBindingPanel({
|
|||||||
.map((g) => ({
|
.map((g) => ({
|
||||||
id: g.id.trim(),
|
id: g.id.trim(),
|
||||||
label: (g.label || "").trim(),
|
label: (g.label || "").trim(),
|
||||||
time_start: typeof g.time_start === "number" ? g.time_start : null,
|
time_start: normalizeTimelineYearValue(g.time_start),
|
||||||
time_end: typeof g.time_end === "number" ? g.time_end : null,
|
time_end: normalizeTimelineYearValue(g.time_end),
|
||||||
isTimelineVisible: Boolean(g.isTimelineVisible),
|
isTimelineVisible: Boolean(g.isTimelineVisible),
|
||||||
|
isOrphan: Boolean(g.isOrphan),
|
||||||
|
timeStatus: resolveTimeStatus(g),
|
||||||
|
timelineStatus: resolveTimelineStatus(g),
|
||||||
isNew: Boolean(g.isNew),
|
isNew: Boolean(g.isNew),
|
||||||
}));
|
}));
|
||||||
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
@@ -85,6 +100,31 @@ export default function GeometryBindingPanel({
|
|||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
}, [bindingSet, effectiveSelectedGeometryId, rows]);
|
}, [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) => {
|
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||||
if (!canFocusGeometry) return;
|
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", 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>
|
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
|
||||||
<label
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
cursor: "pointer",
|
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
|
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
|
||||||
>
|
>
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
type="button"
|
||||||
checked={bindingFilterEnabled}
|
role="switch"
|
||||||
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
|
aria-checked={bindingFilterEnabled}
|
||||||
style={{ width: 14, height: 14 }}
|
aria-label="Toggle geometry binding filter"
|
||||||
|
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 18,
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
|
||||||
|
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
|
||||||
|
transition: "background 140ms ease, border-color 140ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
|
||||||
|
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
|
||||||
|
transition: "background 140ms ease, box-shadow 140ms ease",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
</button>
|
||||||
</label>
|
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
|
<div style={summaryWrapStyle}>
|
||||||
|
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
|
||||||
|
{summary.orphan > 0 ? (
|
||||||
|
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
|
||||||
|
) : null}
|
||||||
|
{summary.timeIssues > 0 ? (
|
||||||
|
<span
|
||||||
|
style={summaryWarningBadgeStyle}
|
||||||
|
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
|
||||||
|
>
|
||||||
|
time {summary.timeIssues}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{summary.filteredOut > 0 ? (
|
||||||
|
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
|
||||||
|
) : null}
|
||||||
|
{summary.hidden > 0 ? (
|
||||||
|
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
@@ -164,8 +247,8 @@ export default function GeometryBindingPanel({
|
|||||||
{collapsed ? null : selectedGeometry ? (
|
{collapsed ? null : selectedGeometry ? (
|
||||||
(() => {
|
(() => {
|
||||||
const isHidden = geometryVisibility[selectedGeometry.id] === false;
|
const isHidden = geometryVisibility[selectedGeometry.id] === false;
|
||||||
const idColor = getGeometryIdColor(selectedGeometry);
|
const isBound = bindingSet.has(selectedGeometry.id);
|
||||||
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
|
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -179,7 +262,7 @@ export default function GeometryBindingPanel({
|
|||||||
opacity: isHidden ? 0.58 : 1,
|
opacity: isHidden ? 0.58 : 1,
|
||||||
boxShadow: "none",
|
boxShadow: "none",
|
||||||
}}
|
}}
|
||||||
title={selectedGeometry.id}
|
title={title}
|
||||||
role={canFocusGeometry ? "button" : undefined}
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||||
@@ -198,19 +281,7 @@ export default function GeometryBindingPanel({
|
|||||||
Selected
|
Selected
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
<span
|
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
|
||||||
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}
|
|
||||||
{selectedGeometry.isNew ? <NewBadge /> : null}
|
{selectedGeometry.isNew ? <NewBadge /> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -225,18 +296,7 @@ export default function GeometryBindingPanel({
|
|||||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
|
||||||
style={{
|
|
||||||
marginTop: 3,
|
|
||||||
fontSize: "11px",
|
|
||||||
color: idColor,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedGeometry.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
@@ -248,8 +308,7 @@ export default function GeometryBindingPanel({
|
|||||||
.map((g) => {
|
.map((g) => {
|
||||||
const isBound = bindingSet.has(g.id);
|
const isBound = bindingSet.has(g.id);
|
||||||
const isHidden = geometryVisibility[g.id] === false;
|
const isHidden = geometryVisibility[g.id] === false;
|
||||||
const idColor = getGeometryIdColor(g);
|
const title = buildGeometryTitle(g, isHidden, isBound);
|
||||||
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={g.id}
|
key={g.id}
|
||||||
@@ -269,7 +328,7 @@ export default function GeometryBindingPanel({
|
|||||||
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
|
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
|
||||||
boxShadow: "none",
|
boxShadow: "none",
|
||||||
}}
|
}}
|
||||||
title={g.id}
|
title={title}
|
||||||
role={canFocusGeometry ? "button" : undefined}
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
onClick={() => handleFocusGeometry(g.id)}
|
onClick={() => handleFocusGeometry(g.id)}
|
||||||
@@ -284,33 +343,10 @@ export default function GeometryBindingPanel({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<GeometryLabel row={g} />
|
||||||
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}
|
|
||||||
{g.isNew ? <NewBadge /> : null}
|
{g.isNew ? <NewBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
|
||||||
style={{
|
|
||||||
fontSize: "11px",
|
|
||||||
color: idColor,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -383,32 +498,91 @@ const boundBadgeStyle: CSSProperties = {
|
|||||||
height: 17,
|
height: 17,
|
||||||
padding: "0 6px",
|
padding: "0 6px",
|
||||||
borderRadius: 999,
|
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)",
|
border: "1px solid rgba(45, 212, 191, 0.5)",
|
||||||
background: "rgba(20, 184, 166, 0.18)",
|
background: "rgba(20, 184, 166, 0.18)",
|
||||||
color: "#99f6e4",
|
color: "#99f6e4",
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 900,
|
|
||||||
lineHeight: 1,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiddenBadgeStyle: CSSProperties = {
|
const hiddenBadgeStyle: CSSProperties = {
|
||||||
display: "inline-flex",
|
...baseBadgeStyle,
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flex: "0 0 auto",
|
|
||||||
height: 17,
|
|
||||||
padding: "0 6px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
background: "rgba(71, 85, 105, 0.32)",
|
background: "rgba(71, 85, 105, 0.32)",
|
||||||
color: "#cbd5e1",
|
color: "#cbd5e1",
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 900,
|
|
||||||
lineHeight: 1,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconButtonStyle: CSSProperties = {
|
const iconButtonStyle: CSSProperties = {
|
||||||
@@ -424,14 +598,6 @@ const iconButtonStyle: CSSProperties = {
|
|||||||
flex: "0 0 auto",
|
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() {
|
function EyeIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
onToggleBindEntityForSelectedGeometry,
|
onToggleBindEntityForSelectedGeometry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
snapshotEntities,
|
snapshotEntityRows,
|
||||||
entityForm,
|
entityForm,
|
||||||
setEntityForm,
|
setEntityForm,
|
||||||
isEntitySubmitting,
|
isEntitySubmitting,
|
||||||
@@ -30,7 +30,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
} = useEditorStore(
|
} = useEditorStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
snapshotEntities: state.snapshotEntities,
|
snapshotEntityRows: state.snapshotEntityRows,
|
||||||
entityForm: state.entityForm,
|
entityForm: state.entityForm,
|
||||||
setEntityForm: state.setEntityForm,
|
setEntityForm: state.setEntityForm,
|
||||||
isEntitySubmitting: state.isEntitySubmitting,
|
isEntitySubmitting: state.isEntitySubmitting,
|
||||||
@@ -53,14 +53,14 @@ export default function ProjectEntityRefsPanel({
|
|||||||
);
|
);
|
||||||
const entityRefs = useMemo(() => {
|
const entityRefs = useMemo(() => {
|
||||||
const byId = new globalThis.Map<string, EntitySnapshot>();
|
const byId = new globalThis.Map<string, EntitySnapshot>();
|
||||||
for (const ref of snapshotEntities || []) {
|
for (const ref of snapshotEntityRows || []) {
|
||||||
const id = String(ref?.id || "").trim();
|
const id = String(ref?.id || "").trim();
|
||||||
if (!id || byId.has(id)) continue;
|
if (!id || byId.has(id)) continue;
|
||||||
if (ref.operation === "delete") continue;
|
if (ref.operation === "delete") continue;
|
||||||
byId.set(id, ref);
|
byId.set(id, ref);
|
||||||
}
|
}
|
||||||
return Array.from(byId.values());
|
return Array.from(byId.values());
|
||||||
}, [snapshotEntities]);
|
}, [snapshotEntityRows]);
|
||||||
const sortedEntityRefs = useMemo(() => {
|
const sortedEntityRefs = useMemo(() => {
|
||||||
const rows = [...(entityRefs || [])];
|
const rows = [...(entityRefs || [])];
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function formatUndoLabel(action: UndoAction) {
|
|||||||
case "snapshot_wikis":
|
case "snapshot_wikis":
|
||||||
case "snapshot_entity_wiki":
|
case "snapshot_entity_wiki":
|
||||||
case "replay":
|
case "replay":
|
||||||
|
case "replays":
|
||||||
case "replay_session":
|
case "replay_session":
|
||||||
case "group":
|
case "group":
|
||||||
return action.label;
|
return action.label;
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ type UseMapInteractionProps = {
|
|||||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
modeRef: React.MutableRefObject<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;
|
allowGeometryEditing: boolean;
|
||||||
selectedFeatureIds: (string | number)[];
|
selectedFeatureIds: (string | number)[];
|
||||||
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
|
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
|
||||||
@@ -40,7 +42,7 @@ export function useMapInteraction({
|
|||||||
mapRef,
|
mapRef,
|
||||||
mode,
|
mode,
|
||||||
modeRef,
|
modeRef,
|
||||||
draftRef,
|
renderDraftRef,
|
||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
onSelectFeatureIdsRef,
|
onSelectFeatureIdsRef,
|
||||||
@@ -153,7 +155,7 @@ export function useMapInteraction({
|
|||||||
allowGeometryEditing
|
allowGeometryEditing
|
||||||
? (feature) => {
|
? (feature) => {
|
||||||
const rawId = feature.id ?? feature.properties?.id;
|
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)
|
(item) => String(item.properties.id) === String(rawId)
|
||||||
);
|
);
|
||||||
editingEngineRef.current?.beginEditing(
|
editingEngineRef.current?.beginEditing(
|
||||||
@@ -163,7 +165,7 @@ export function useMapInteraction({
|
|||||||
: undefined,
|
: undefined,
|
||||||
allowGeometryEditing
|
allowGeometryEditing
|
||||||
? (id: string | number) => {
|
? (id: string | number) => {
|
||||||
const originalFeature = draftRef.current.features.find(
|
const originalFeature = renderDraftRef.current.features.find(
|
||||||
(item) => String(item.properties.id) === String(id)
|
(item) => String(item.properties.id) === String(id)
|
||||||
);
|
);
|
||||||
if (!originalFeature) return;
|
if (!originalFeature) return;
|
||||||
@@ -312,7 +314,7 @@ export function useMapInteraction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentFeature =
|
const currentFeature =
|
||||||
draftRef.current.features.find(
|
renderDraftRef.current.features.find(
|
||||||
(item) => String(item.properties.id) === String(rawFeatureId)
|
(item) => String(item.properties.id) === String(rawFeatureId)
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
|
|||||||
|
|
||||||
type UseMapSyncProps = {
|
type UseMapSyncProps = {
|
||||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
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;
|
labelContextDraft?: FeatureCollection;
|
||||||
labelTimelineYear?: number | null;
|
labelTimelineYear?: number | null;
|
||||||
backgroundVisibility: BackgroundLayerVisibility;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
geometryVisibility?: Record<string, boolean>;
|
geometryVisibility?: Record<string, boolean>;
|
||||||
selectedFeatureIds: (string | number)[];
|
selectedFeatureIds: (string | number)[];
|
||||||
respectBindingFilter: boolean;
|
applyGeometryBindingFilter: boolean;
|
||||||
fitToDraftBounds: boolean;
|
fitToDraftBounds: boolean;
|
||||||
fitBoundsKey?: string | number | null;
|
fitBoundsKey?: string | number | null;
|
||||||
highlightFeatures?: FeatureCollection | null;
|
highlightFeatures?: FeatureCollection | null;
|
||||||
@@ -44,13 +48,13 @@ type UseMapSyncProps = {
|
|||||||
|
|
||||||
export function useMapSync({
|
export function useMapSync({
|
||||||
mapRef,
|
mapRef,
|
||||||
draft,
|
renderDraft,
|
||||||
labelContextDraft,
|
labelContextDraft,
|
||||||
labelTimelineYear,
|
labelTimelineYear,
|
||||||
backgroundVisibility,
|
backgroundVisibility,
|
||||||
geometryVisibility,
|
geometryVisibility,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
respectBindingFilter,
|
applyGeometryBindingFilter,
|
||||||
fitToDraftBounds,
|
fitToDraftBounds,
|
||||||
fitBoundsKey,
|
fitBoundsKey,
|
||||||
highlightFeatures,
|
highlightFeatures,
|
||||||
@@ -62,13 +66,13 @@ export function useMapSync({
|
|||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
geolocationCenteredRef,
|
geolocationCenteredRef,
|
||||||
}: UseMapSyncProps) {
|
}: UseMapSyncProps) {
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
|
||||||
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||||
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
|
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
|
||||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||||
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||||
const respectBindingFilterRef = useRef(respectBindingFilter);
|
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
|
||||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
||||||
@@ -77,13 +81,13 @@ export function useMapSync({
|
|||||||
|
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
|
||||||
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
||||||
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
|
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
|
||||||
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
||||||
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
||||||
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
||||||
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
|
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
|
||||||
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||||
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||||
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
||||||
@@ -94,8 +98,8 @@ export function useMapSync({
|
|||||||
fitBoundsAppliedRef.current = false;
|
fitBoundsAppliedRef.current = false;
|
||||||
}, [fitBoundsKey]);
|
}, [fitBoundsKey]);
|
||||||
|
|
||||||
const applyDraftToMap = useCallback((
|
const applyRenderDraftToMap = useCallback((
|
||||||
fc: FeatureCollection,
|
renderFc: FeatureCollection,
|
||||||
labelContextOverride?: FeatureCollection,
|
labelContextOverride?: FeatureCollection,
|
||||||
selectedIdsOverride?: (string | number)[],
|
selectedIdsOverride?: (string | number)[],
|
||||||
highlightFeaturesOverride?: FeatureCollection | null
|
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 currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
|
||||||
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
|
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
|
||||||
? highlightFeaturesOverride
|
? highlightFeaturesOverride
|
||||||
: highlightFeaturesRef.current;
|
: highlightFeaturesRef.current;
|
||||||
|
|
||||||
const visibleDraftRaw = respectBindingFilterRef.current
|
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
|
||||||
? filterDraftByBinding(labelContext, currentSelectedIds, highlightFeaturesVal)
|
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
|
||||||
: labelContext;
|
: renderFc;
|
||||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
|
||||||
const labelTimelineYear = labelTimelineYearRef.current;
|
const labelTimelineYear = labelTimelineYearRef.current;
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
|
||||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
||||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
|
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
|
||||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
|
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
|
||||||
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
|
||||||
|
|
||||||
countriesSource.setData(labeledGeometries);
|
countriesSource.setData(labeledGeometries);
|
||||||
placesSource.setData(labeledPoints);
|
placesSource.setData(labeledPoints);
|
||||||
@@ -147,7 +151,7 @@ export function useMapSync({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft);
|
||||||
}
|
}
|
||||||
}, [mapRef]);
|
}, [mapRef]);
|
||||||
|
|
||||||
@@ -206,24 +210,24 @@ export function useMapSync({
|
|||||||
}, [imageOverlay, mapRef]);
|
}, [imageOverlay, mapRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyDraftToMap(draft, labelContextDraft, selectedFeatureIds, highlightFeatures);
|
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures);
|
||||||
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||||
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
|
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) {
|
if (!stillExists) {
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
draft,
|
renderDraft,
|
||||||
labelContextDraft,
|
labelContextDraft,
|
||||||
labelTimelineYear,
|
labelTimelineYear,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
respectBindingFilter,
|
applyGeometryBindingFilter,
|
||||||
geometryVisibility,
|
geometryVisibility,
|
||||||
highlightFeatures,
|
highlightFeatures,
|
||||||
applyDraftToMap,
|
applyRenderDraftToMap,
|
||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -259,7 +263,7 @@ export function useMapSync({
|
|||||||
}, [focusRequestKey, mapRef]);
|
}, [focusRequestKey, mapRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyDraftToMap,
|
applyRenderDraftToMap,
|
||||||
applyHighlightToMap,
|
applyHighlightToMap,
|
||||||
tryCenterToUserLocation,
|
tryCenterToUserLocation,
|
||||||
applyImageOverlayToMap: () => {
|
applyImageOverlayToMap: () => {
|
||||||
|
|||||||
@@ -214,11 +214,21 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggleContainer:focus-visible {
|
||||||
|
outline: 2px solid rgba(52, 211, 153, 0.8);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.toggleTrack {
|
.toggleTrack {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -55,9 +55,15 @@ export default function TimelineBar({
|
|||||||
>
|
>
|
||||||
<div className={styles.flexWrapper}>
|
<div className={styles.flexWrapper}>
|
||||||
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
|
{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)"}
|
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
|
||||||
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
|
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
|
||||||
|
onClick={() => onFilterEnabledChange(!filterEnabled)}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -67,15 +73,7 @@ export default function TimelineBar({
|
|||||||
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
|
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<input
|
</button>
|
||||||
type="checkbox"
|
|
||||||
checked={filterEnabled}
|
|
||||||
onChange={(e) => onFilterEnabledChange(e.target.checked)}
|
|
||||||
disabled={effectiveDisabled}
|
|
||||||
aria-label="Toggle timeline filter"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : null}
|
) : null}
|
||||||
<span className={styles.labelBounds}>{formatYear(lower)}</span>
|
<span className={styles.labelBounds}>{formatYear(lower)}</span>
|
||||||
<input
|
<input
|
||||||
@@ -133,4 +131,3 @@ function formatYear(year: number): string {
|
|||||||
}
|
}
|
||||||
return `${year}`;
|
return `${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ let quillLinkSanitizePatched = false;
|
|||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||||
|
onRemoveWiki?: (wikiId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampTitle(title: string) {
|
function clampTitle(title: string) {
|
||||||
@@ -63,7 +64,7 @@ function clampTitle(title: string) {
|
|||||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
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(
|
const { wikis, requestedActiveId } = useEditorStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
wikis: state.snapshotWikis,
|
wikis: state.snapshotWikis,
|
||||||
@@ -252,7 +253,11 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeWiki = (id: string) => {
|
const removeWiki = (id: string) => {
|
||||||
|
if (onRemoveWiki) {
|
||||||
|
onRemoveWiki(id);
|
||||||
|
} else {
|
||||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||||
|
}
|
||||||
if (activeId === id) setActiveId(null);
|
if (activeId === id) setActiveId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
|
* - 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.
|
* - 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.
|
* - 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 ----
|
// ---- Root request ----
|
||||||
@@ -53,6 +54,12 @@ export type FeatureProperties = {
|
|||||||
entity_ids?: string[];
|
entity_ids?: string[];
|
||||||
entity_name?: string | null;
|
entity_name?: string | null;
|
||||||
entity_names?: string[];
|
entity_names?: string[];
|
||||||
|
entity_label_candidates?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
}>;
|
||||||
entity_type_id?: string | null;
|
entity_type_id?: string | null;
|
||||||
point_label?: string | null;
|
point_label?: string | null;
|
||||||
line_label?: string | null;
|
line_label?: string | null;
|
||||||
@@ -85,6 +92,8 @@ export type EntitySnapshot = {
|
|||||||
operation?: EntitySnapshotOperation;
|
operation?: EntitySnapshotOperation;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometrySnapshot = {
|
export type GeometrySnapshot = {
|
||||||
@@ -267,15 +276,11 @@ export type ReplayGeoFunctionParamTupleDocs = {
|
|||||||
padding?: number,
|
padding?: number,
|
||||||
duration?: 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];
|
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
|
||||||
show_geometries: [geometry_ids: string[]];
|
show_geometries: [geometry_ids: string[]];
|
||||||
hide_geometries: [geometry_ids: string[]];
|
hide_geometries: [geometry_ids: string[]];
|
||||||
fit_to_geometries: [
|
fit_to_geometries: [geometry_ids: string[], duration?: number];
|
||||||
geometry_ids: string[],
|
|
||||||
padding?: number,
|
|
||||||
duration?: number,
|
|
||||||
];
|
|
||||||
orbit_camera_around_geometry: [
|
orbit_camera_around_geometry: [
|
||||||
geometry_id: string,
|
geometry_id: string,
|
||||||
zoom?: number,
|
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.
|
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
|
## 2. Cấu trúc thư mục nên ưu tiên hiểu
|
||||||
|
|
||||||
- `src/uhm/components/editor/`
|
- `src/uhm/components/editor/`
|
||||||
@@ -40,14 +48,17 @@ Editor có 3 tầng dữ liệu:
|
|||||||
|
|
||||||
1. `baselineSnapshot`
|
1. `baselineSnapshot`
|
||||||
- snapshot gốc của session
|
- snapshot gốc của session
|
||||||
2. `initialData`
|
2. `baselineFeatureCollection`
|
||||||
- `FeatureCollection` rehydrate từ snapshot đó
|
- `FeatureCollection` rehydrate từ snapshot đó
|
||||||
3. `draft`
|
- seed/reset cho `useEditorState()`
|
||||||
|
3. `mainDraft`
|
||||||
- working copy để user sửa trên map
|
- 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:
|
Khi commit:
|
||||||
|
|
||||||
- geometry đi từ `draft`
|
- geometry đi từ `mainDraft`
|
||||||
- entity/wiki/link đi từ snapshot collections
|
- entity/wiki/link đi từ snapshot collections
|
||||||
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
|
- `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ữ:
|
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
|
- 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 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
|
- 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
|
## 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
|
### 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
|
## 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/`.
|
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.
|
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
|
## 1. Cách mở editor
|
||||||
|
|
||||||
- `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải.
|
- `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`
|
- `UndoListPanel`
|
||||||
- Khu vực giữa
|
- Khu vực giữa
|
||||||
- `Map`
|
- `Map`
|
||||||
- `TimelineBar` khi không ở `replay`
|
- `TimelineBar` khi không ở `replay`; trong `replay_preview` phụ thuộc action `timeline`
|
||||||
- Cột phải (`BackgroundLayersPanel`)
|
- Cột phải (`BackgroundLayersPanel`)
|
||||||
- Search hợp nhất
|
- Search hợp nhất
|
||||||
- Geometry Binding
|
- Geometry Binding
|
||||||
@@ -40,6 +47,7 @@ Hai cột hai bên đều resize được bằng drag handle.
|
|||||||
- `add-path`
|
- `add-path`
|
||||||
- `add-circle`
|
- `add-circle`
|
||||||
- `replay`
|
- `replay`
|
||||||
|
- `replay_preview`
|
||||||
|
|
||||||
Ý nghĩa thực tế:
|
Ý 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-line`: vẽ `LineString`.
|
||||||
- `add-path`: vẽ `LineString` có render arrow layer cho route.
|
- `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`.
|
- `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
|
## 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`
|
- `entity`
|
||||||
- tìm local + backend theo tên/mô tả
|
- 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`
|
- `wiki`
|
||||||
- tìm backend theo title
|
- tìm backend theo title
|
||||||
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
|
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
|
||||||
- `geo`
|
- `geo`
|
||||||
- tìm geometry theo tên entity
|
- tìm geometry theo tên entity
|
||||||
- nút `Import` sẽ import geometry vào draft hiện tại
|
- 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
|
- import sẽ tự tắt timeline filter để geometry mới import không bị ẩn
|
||||||
|
|
||||||
## 9. Entity và binding
|
## 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.
|
- Bind/unbind với geometry khác trong project.
|
||||||
- Có nút focus để zoom vào geometry trong list binding.
|
- 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.
|
- 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
|
## 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 wiki dirty
|
||||||
- `+1` nếu danh sách entity dirty
|
- `+1` nếu danh sách entity dirty
|
||||||
- `+1` nếu danh sách entity-wiki dirty
|
- `+1` nếu danh sách entity-wiki dirty
|
||||||
|
- `+1` nếu replay script dirty
|
||||||
|
|
||||||
### Commit
|
### Commit
|
||||||
|
|
||||||
`commitSection()`:
|
`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
|
- gửi `snapshot_json` lên API tạo commit
|
||||||
- nếu thành công:
|
- nếu thành công:
|
||||||
- reset baseline sang snapshot vừa commit
|
- 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`
|
- 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 thay đổi chưa commit
|
||||||
|
- không submit nếu còn orphan geometry
|
||||||
|
|
||||||
### Restore
|
### Restore
|
||||||
|
|
||||||
`CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là:
|
`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
|
- load snapshot từ commit cũ vào FE
|
||||||
- không đổi head commit trên backend
|
- 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
|
- 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`
|
- 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
|
- 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/state/useEditorState.ts`
|
||||||
- `src/uhm/lib/editor/project/useProjectCommands.ts`
|
- `src/uhm/lib/editor/project/useProjectCommands.ts`
|
||||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||||
|
- `src/uhm/doc/editor_replay_actions.md`
|
||||||
|
|
||||||
## 1. Kết luận ngắn
|
## 1. Kết luận ngắn
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ Replay mode hiện tại có 2 lớp state:
|
|||||||
|
|
||||||
- `activeReplayDraft`
|
- `activeReplayDraft`
|
||||||
- là `BattleReplay` đang chỉnh
|
- 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`
|
- `replayDraft`
|
||||||
- là `FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
|
- là `FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
|
||||||
- chỉ dùng để map/render/select trong replay mode
|
- chỉ dùng để map/render/select trong replay mode
|
||||||
@@ -125,6 +126,10 @@ Nên khi `mode === "replay"`:
|
|||||||
- `editor.draftRef` trỏ vào `replayDraftRef`
|
- `editor.draftRef` trỏ vào `replayDraftRef`
|
||||||
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
|
- 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
|
## 7. Replay mode còn sửa geometry không
|
||||||
|
|
||||||
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:
|
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
|
||||||
|
|
||||||
- `createFeature`
|
- `createFeature`
|
||||||
- `createFeatureWithSnapshotEntities`
|
- `createFeatureWithSnapshotEntityRows`
|
||||||
- `patchFeatureProperties`
|
- `patchFeatureProperties`
|
||||||
- `patchFeaturePropertiesBatch`
|
- `patchFeaturePropertiesBatch`
|
||||||
- `updateFeature`
|
- `updateFeature`
|
||||||
@@ -161,6 +166,8 @@ Undo replay vẫn riêng ở:
|
|||||||
|
|
||||||
- `replayUndoStack`
|
- `replayUndoStack`
|
||||||
|
|
||||||
|
Danh sách action và tuple `params` nằm ở `editor_replay_actions.md`.
|
||||||
|
|
||||||
## 9. Khi nào replay được flush về `replays[]`
|
## 9. Khi nào replay được flush về `replays[]`
|
||||||
|
|
||||||
`activeReplayDraft` chỉ là session đang mở.
|
`activeReplayDraft` chỉ là session đang mở.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Editor đang tách làm hai khối:
|
|||||||
|
|
||||||
- `useEditorSessionState()`
|
- `useEditorSessionState()`
|
||||||
- state UI, session, form, project, timeline, background, wiki
|
- state UI, session, form, project, timeline, background, wiki
|
||||||
- `useEditorState(initialData, snapshotUndo)`
|
- `useEditorState(baselineFeatureCollection, snapshotUndo)`
|
||||||
- state draft hình học, diff và undo
|
- state draft hình học, diff và undo
|
||||||
|
|
||||||
Nói ngắn gọn:
|
Nói ngắn gọn:
|
||||||
@@ -19,26 +19,34 @@ Nói ngắn gọn:
|
|||||||
|
|
||||||
## 2. State geometry trung tâm
|
## 2. State geometry trung tâm
|
||||||
|
|
||||||
### `initialData`
|
### `baselineFeatureCollection`
|
||||||
|
|
||||||
- Nằm ở `useEditorSessionState()`
|
- 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
|
- Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng
|
||||||
|
|
||||||
### `draft`
|
### `mainDraft`
|
||||||
|
|
||||||
- Nằm trong `useEditorState()`
|
- 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
|
- 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`
|
### `draftRef`
|
||||||
|
|
||||||
- Bản ref của `draft`
|
- Ref nội bộ tương ứng với draft trong `useEditorState()`
|
||||||
- Đượ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
|
- Đượ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`
|
### `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
|
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
|
||||||
|
|
||||||
### `changes`
|
### `changes`
|
||||||
@@ -55,7 +63,7 @@ Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay
|
|||||||
### `changeCount`
|
### `changeCount`
|
||||||
|
|
||||||
- Số lượng geometry thay đổi hiện tại
|
- 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
|
## 3. Undo state
|
||||||
|
|
||||||
@@ -70,12 +78,17 @@ Kiểu action hiện có:
|
|||||||
- `snapshot_entities`
|
- `snapshot_entities`
|
||||||
- `snapshot_wikis`
|
- `snapshot_wikis`
|
||||||
- `snapshot_entity_wiki`
|
- `snapshot_entity_wiki`
|
||||||
|
- `replay`
|
||||||
|
- `replays`
|
||||||
|
- `replay_session`
|
||||||
- `group`
|
- `group`
|
||||||
|
|
||||||
Ý nghĩa:
|
Ý nghĩa:
|
||||||
|
|
||||||
- geometry create/delete/update/properties undo được trực tiếp trên `draft`
|
- 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`
|
- 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
|
- `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.
|
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`.
|
`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ỏ:
|
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
|
||||||
|
|
||||||
@@ -127,7 +156,7 @@ Ngoài ra còn có:
|
|||||||
- `baselineSnapshot`
|
- `baselineSnapshot`
|
||||||
- `commitTitle`
|
- `commitTitle`
|
||||||
|
|
||||||
### 4.4. Timeline state
|
### 4.5. Timeline state
|
||||||
|
|
||||||
`useTimelineState()` giữ:
|
`useTimelineState()` giữ:
|
||||||
|
|
||||||
@@ -139,7 +168,7 @@ Ngoài ra còn có:
|
|||||||
Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`.
|
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.
|
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ữ:
|
`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`.
|
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
|
||||||
|
|
||||||
### 4.6. Wiki/session state
|
### 4.7. Wiki/session state
|
||||||
|
|
||||||
`useWikiSessionState()` giữ:
|
`useWikiSessionState()` giữ:
|
||||||
|
|
||||||
@@ -159,11 +188,12 @@ Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisi
|
|||||||
|
|
||||||
## 5. Snapshot state
|
## 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`
|
- `snapshotWikis`
|
||||||
- `snapshotEntityWikiLinks`
|
- `snapshotEntityWikiLinks`
|
||||||
|
- `replays` / `effectiveReplays`
|
||||||
|
|
||||||
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
|
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
|
## 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
|
- 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`
|
- loại bỏ các row `delete`
|
||||||
- dedupe theo `id`
|
- dedupe theo `id`
|
||||||
@@ -220,6 +265,7 @@ Nó được cập nhật khi:
|
|||||||
- `+1` nếu wiki dirty
|
- `+1` nếu wiki dirty
|
||||||
- `+1` nếu entities dirty
|
- `+1` nếu entities dirty
|
||||||
- `+1` nếu entity-wiki 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.
|
Đâ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:
|
Dirty check của:
|
||||||
|
|
||||||
- `snapshotWikis`
|
- `snapshotWikis`
|
||||||
- `snapshotEntities`
|
- `snapshotEntityRows`
|
||||||
- `snapshotEntityWikiLinks`
|
- `snapshotEntityWikiLinks`
|
||||||
|
- `editor.effectiveReplays`
|
||||||
|
|
||||||
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
|
đề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:
|
Hiệu ứng:
|
||||||
|
|
||||||
- `initialData` đổi
|
- `baselineFeatureCollection` đổi
|
||||||
- `useEditorState()` reset `draft`
|
- `useEditorState()` reset `draft`
|
||||||
- `undoStack` bị clear
|
- `undoStack` bị clear
|
||||||
- baseline map được build lại
|
- 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
|
- 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
|
- 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
|
- 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:
|
Nguồn thật:
|
||||||
|
|
||||||
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
|
- `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
|
## 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ả **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ả **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
|
- 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:
|
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 đó:
|
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:
|
2. app parse style JSON để lấy:
|
||||||
- `raster source` từ `goong_satellite.json`
|
- `raster source` từ `goong_satellite.json`
|
||||||
- `sources + layers` cần thiết từ `goong_map_web.json`
|
- `sources + layers` cần thiết từ `goong_map_web.json`
|
||||||
3. app `map.addSource(...)` và `map.addLayer(...)` thủ công
|
3. nếu source dùng `url`, app tiếp tục fetch source manifest qua proxy trong `tiles.ts`
|
||||||
4. từ thời điểm đó, **MapLibre tự request tiếp** các `source.url`
|
4. app rewrite `tiles[]` về backend proxy rồi `map.addSource(...)` và `map.addLayer(...)` thủ công
|
||||||
5. rồi từ các source manifest đó, **MapLibre lại tự request tiếp** các tile URLs nằm trong `tiles[]`
|
5. từ thời điểm đó, **MapLibre tự request tiếp** tile/font URLs đã là URL proxy
|
||||||
|
|
||||||
Hệ quả:
|
Hệ quả:
|
||||||
|
|
||||||
- nếu BE chỉ proxy `assets/*.json` thì **chưa đủ**
|
- 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. 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=...`
|
1. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_satellite.json`
|
||||||
2. `https://tiles.goong.io/assets/goong_map_web.json?api_key=...`
|
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:
|
Nguồn trong code:
|
||||||
|
|
||||||
- `GOONG_SATELLITE_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:15)
|
- `GOONG_SATELLITE_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:8)
|
||||||
- `GOONG_VECTOR_OVERLAY_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:19)
|
- `GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:9)
|
||||||
- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:211)
|
- `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:
|
Mục đích:
|
||||||
|
|
||||||
@@ -63,9 +70,9 @@ Mục đích:
|
|||||||
- `Country Labels`
|
- `Country Labels`
|
||||||
- `Rivers`
|
- `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:
|
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`
|
- `sources/goong.json`
|
||||||
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
|
- 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.
|
Đâ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[]`
|
- `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à:
|
Tức là runtime thật của frontend hiện tại là:
|
||||||
|
|
||||||
1. fetch style JSON
|
1. FE fetch style JSON qua proxy
|
||||||
2. fetch source manifest
|
2. FE fetch source manifest qua proxy
|
||||||
3. fetch tile URL bên trong source manifest
|
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
|
## 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
|
- 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[]`
|
- 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
|
## 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:
|
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:
|
Nguồn trong code:
|
||||||
|
|
||||||
@@ -201,7 +212,8 @@ Có 2 cách:
|
|||||||
|
|
||||||
#### Cách A: Transparent proxy
|
#### 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:
|
Ư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:
|
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ộ
|
#### 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.
|
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. Contract backend được khuyến nghị
|
||||||
|
|
||||||
### 6.1. Proxy style JSON
|
### 6.1. Proxy style JSON
|
||||||
|
|
||||||
#### `GET /proxy/goong/assets/goong_satellite.json`
|
#### `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
|
||||||
|
|
||||||
Upstream:
|
Upstream:
|
||||||
|
|
||||||
@@ -241,15 +255,16 @@ Backend phải:
|
|||||||
|
|
||||||
- fetch upstream bằng key server-side
|
- fetch upstream bằng key server-side
|
||||||
- parse JSON
|
- 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
|
- có thể giữ nguyên các field khác
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
|
|
||||||
- `Content-Type: application/json`
|
- `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:
|
Upstream:
|
||||||
|
|
||||||
@@ -259,17 +274,18 @@ Backend phải:
|
|||||||
|
|
||||||
- fetch upstream bằng key server-side
|
- fetch upstream bằng key server-side
|
||||||
- parse JSON
|
- 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
|
- có thể giữ nguyên các field khác
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
|
|
||||||
- `Content-Type: application/json`
|
- `Content-Type: application/json`
|
||||||
- body: style JSON đã rewrite
|
- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
|
||||||
|
|
||||||
### 6.2. Proxy source manifests
|
### 6.2. Proxy source manifests
|
||||||
|
|
||||||
#### `GET /proxy/goong/sources/satellite.json`
|
#### `GET /proxy/tiles.goong.io/sources/satellite.json`
|
||||||
|
|
||||||
Upstream:
|
Upstream:
|
||||||
|
|
||||||
@@ -279,7 +295,8 @@ Backend phải:
|
|||||||
|
|
||||||
- fetch upstream
|
- fetch upstream
|
||||||
- parse JSON
|
- 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:
|
- giữ nguyên metadata quan trọng:
|
||||||
- `tileSize`
|
- `tileSize`
|
||||||
- `minzoom`
|
- `minzoom`
|
||||||
@@ -291,9 +308,9 @@ Backend phải:
|
|||||||
Response:
|
Response:
|
||||||
|
|
||||||
- `Content-Type: application/json`
|
- `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:
|
Upstream:
|
||||||
|
|
||||||
@@ -303,10 +320,11 @@ Backend phải:
|
|||||||
|
|
||||||
- fetch upstream
|
- fetch upstream
|
||||||
- parse JSON
|
- 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
|
- giữ nguyên metadata tilejson khác
|
||||||
|
|
||||||
#### `GET /proxy/goong/sources/goong.json`
|
#### `GET /proxy/tiles.goong.io/sources/goong.json`
|
||||||
|
|
||||||
Upstream:
|
Upstream:
|
||||||
|
|
||||||
@@ -316,22 +334,17 @@ Backend phải:
|
|||||||
|
|
||||||
- fetch upstream
|
- fetch upstream
|
||||||
- parse JSON
|
- 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
|
- giữ nguyên metadata tilejson khác
|
||||||
|
|
||||||
### 6.3. Proxy tile endpoints
|
### 6.3. Proxy tile endpoints
|
||||||
|
|
||||||
Backend bắt buộc phải có route để trả tile thật.
|
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/*`
|
- `GET /proxy/tiles.goong.io/...`
|
||||||
|
|
||||||
hoặc explicit hơn theo source:
|
|
||||||
|
|
||||||
- `GET /proxy/goong/tiles/satellite/...`
|
|
||||||
- `GET /proxy/goong/tiles/base/...`
|
|
||||||
- `GET /proxy/goong/tiles/goong/...`
|
|
||||||
|
|
||||||
Yêu cầu:
|
Yêu cầu:
|
||||||
|
|
||||||
@@ -357,8 +370,9 @@ Luồng:
|
|||||||
|
|
||||||
1. FE đọc `goong_satellite.json`
|
1. FE đọc `goong_satellite.json`
|
||||||
2. FE lấy `sources.satellite`
|
2. FE lấy `sources.satellite`
|
||||||
3. MapLibre gọi `sources/satellite.json`
|
3. FE gọi `sources/satellite.json` qua proxy trong `tiles.ts`
|
||||||
4. MapLibre gọi raster tile URLs trong `tiles[]`
|
4. FE rewrite `tiles[]` về proxy URL
|
||||||
|
5. MapLibre gọi raster tile URLs đã rewrite
|
||||||
|
|
||||||
BE cần cover:
|
BE cần cover:
|
||||||
|
|
||||||
@@ -372,9 +386,10 @@ Luồng:
|
|||||||
|
|
||||||
1. FE đọc `goong_map_web.json`
|
1. FE đọc `goong_map_web.json`
|
||||||
2. FE lấy selected layers + selected sources
|
2. FE lấy selected layers + selected sources
|
||||||
3. MapLibre gọi `sources/base.json`
|
3. FE gọi `sources/base.json` qua proxy trong `tiles.ts`
|
||||||
4. MapLibre gọi `sources/goong.json`
|
4. FE gọi `sources/goong.json` qua proxy trong `tiles.ts`
|
||||||
5. MapLibre gọi vector tile URLs của 2 source manifest này
|
5. FE rewrite `tiles[]` về proxy URL
|
||||||
|
6. MapLibre gọi vector tile URLs đã rewrite
|
||||||
|
|
||||||
BE cần cover:
|
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à:
|
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`
|
1. proxy `tiles.goong.io/assets/goong_satellite.json`
|
||||||
2. proxy `assets/goong_map_web.json`
|
2. proxy `tiles.goong.io/assets/goong_map_web.json`
|
||||||
3. proxy `sources/satellite.json`
|
3. proxy `tiles.goong.io/sources/satellite.json`
|
||||||
4. proxy `sources/base.json`
|
4. proxy `tiles.goong.io/sources/base.json`
|
||||||
5. proxy `sources/goong.json`
|
5. proxy `tiles.goong.io/sources/goong.json`
|
||||||
6. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
|
6. proxy `tiles.goong.io/fonts/{fontstack}/{range}.pbf`
|
||||||
7. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
|
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/goong.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
|
## 9. Những gì BE chưa cần làm ngay
|
||||||
|
|
||||||
Cho flow hiện tại, BE **chưa cần**:
|
Cho flow hiện tại, BE **chưa cần**:
|
||||||
|
|
||||||
- proxy Goong `glyphs`
|
|
||||||
- proxy Goong `sprite`
|
- proxy Goong `sprite`
|
||||||
- proxy geocoding / directions / autocomplete
|
- 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:
|
Nếu muốn làm ít rủi ro nhất:
|
||||||
|
|
||||||
1. làm proxy `assets/*.json`
|
1. làm proxy `assets/*.json`
|
||||||
2. rewrite `sources.*.url`
|
2. sanitize nested `api_key` trong style JSON
|
||||||
3. làm proxy `sources/*.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
|
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
|
## Gợi ý mapping cho UI
|
||||||
|
|
||||||
- `Country Borders` -> `boundary-land-type-0` + `boundary-land-type-0-bg`
|
Mapping hiện tại trong `tiles.ts` là heuristic runtime, không hardcode đúng từng id này:
|
||||||
- `Province Borders` -> `boundary-land-type-1` + `boundary-land-type-1-bg`
|
|
||||||
- `District Borders` -> `boundary-land-type-2` + `boundary-land-type-2-bg`
|
- `Country Borders` -> ưu tiên `boundary-land-type-0`, bỏ `boundary-land-type-0-bg`
|
||||||
- `Country Labels` -> `place-country-*`, `place-city-capital*`, `place-city*`, `place-town*`
|
- `Province Borders` -> ưu tiên `boundary-land-type-1`, bỏ `boundary-land-type-1-bg`
|
||||||
- `Rivers` -> `water`, `water-shadow`, `river-name-*`, `lake-name_*`
|
- `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
|
- luồng request thật của frontend hiện tại
|
||||||
- backend cần proxy chỗ nào
|
- backend cần proxy chỗ nào
|
||||||
- backend cần rewrite chỗ nào
|
- backend cần sanitize/rewrite chỗ nào
|
||||||
- trade-off hiệu suất nếu proxy/rewrite toàn bộ Goong
|
- 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
|
- khuyến nghị triển khai thực dụng cho team BE
|
||||||
|
|
||||||
Tài liệu liên quan:
|
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 đó:
|
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
|
2. FE parse style JSON
|
||||||
3. FE lấy ra:
|
3. FE lấy ra:
|
||||||
- raster source cho satellite
|
- raster source cho satellite
|
||||||
- selected vector sources/layers cho borders, labels, rivers
|
- selected vector sources/layers cho borders, labels, rivers
|
||||||
4. FE `addSource()` và `addLayer()` thủ công
|
4. FE gọi source manifest qua `buildGoongProxyUrl(...)` nếu style source có `url`
|
||||||
5. MapLibre tự request tiếp `source.url`
|
5. FE rewrite `tiles[]` về proxy URL rồi `addSource()` và `addLayer()` thủ công
|
||||||
6. Từ source manifest, MapLibre tự request tiếp các tile URLs trong `tiles[]`
|
6. MapLibre request tile/font URLs đã là URL proxy
|
||||||
|
|
||||||
Điểm quan trọng:
|
Điểm quan trọng:
|
||||||
|
|
||||||
- browser có thể không chỉ gọi `assets/*.json`
|
- browser không được gọi trực tiếp `tiles.goong.io`
|
||||||
- browser sẽ đi sâu thêm ít nhất 2 tầng:
|
- browser vẫn sẽ đi qua backend proxy ở các tầng:
|
||||||
|
- `assets/*.json`
|
||||||
- `sources/*.json`
|
- `sources/*.json`
|
||||||
- tile URLs trong `tiles[]`
|
- tile URLs trong `tiles[]`
|
||||||
|
- `fonts/{fontstack}/{range}.pbf`
|
||||||
|
|
||||||
## 2. Luồng request hiện tại
|
## 2. Luồng request hiện tại
|
||||||
|
|
||||||
@@ -48,20 +50,29 @@ Thay vào đó:
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant FE as Frontend
|
participant FE as Frontend
|
||||||
participant GL as MapLibre
|
participant GL as MapLibre
|
||||||
|
participant BE as Backend Proxy
|
||||||
participant GO as Goong
|
participant GO as Goong
|
||||||
|
|
||||||
FE->>GO: GET assets/goong_satellite.json?api_key=...
|
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
|
||||||
FE->>GO: GET assets/goong_map_web.json?api_key=...
|
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=...
|
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
|
||||||
GL->>GO: GET sources/base.json?api_key=...
|
|
||||||
GL->>GO: GET sources/goong.json?api_key=...
|
|
||||||
|
|
||||||
GL->>GO: GET raster tile URLs from satellite tiles[]
|
GL->>BE: GET /proxy/tiles.goong.io/...tile...
|
||||||
GL->>GO: GET vector tile URLs from base tiles[]
|
GL->>BE: GET /proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf
|
||||||
GL->>GO: GET vector tile URLs from goong tiles[]
|
BE->>GO: fetch upstream tile/font bytes
|
||||||
|
GO-->>BE: bytes
|
||||||
|
BE-->>GL: bytes
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Mục tiêu của backend proxy
|
## 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
|
1. browser chỉ gọi domain BE
|
||||||
2. BE gọi Goong bằng key server-side
|
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:
|
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
|
### 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`
|
- `sources.*.url`
|
||||||
|
- `glyphs`
|
||||||
|
- `sprite`
|
||||||
|
|
||||||
Ví dụ:
|
Ví dụ:
|
||||||
|
|
||||||
- từ `https://tiles.goong.io/sources/base.json?api_key=...`
|
- 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
|
### 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[]`
|
- mọi phần tử trong `tiles[]`
|
||||||
|
|
||||||
Ví dụ:
|
Ví dụ:
|
||||||
|
|
||||||
- từ `https://.../{z}/{x}/{y}...api_key=...`
|
- 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
|
### 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
|
### 5.1. Style endpoints
|
||||||
|
|
||||||
- `GET /proxy/goong/assets/goong_satellite.json`
|
- `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
|
||||||
- `GET /proxy/goong/assets/goong_map_web.json`
|
- `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
|
||||||
|
|
||||||
Nhiệm vụ:
|
Nhiệm vụ:
|
||||||
|
|
||||||
- gọi upstream Goong bằng key server-side
|
- gọi upstream Goong bằng key server-side
|
||||||
- parse JSON
|
- parse JSON
|
||||||
- rewrite `sources.*.url`
|
- strip `api_key` khỏi nested URL
|
||||||
- trả JSON đã rewrite
|
- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...`
|
||||||
|
|
||||||
### 5.2. Source endpoints
|
### 5.2. Source endpoints
|
||||||
|
|
||||||
- `GET /proxy/goong/sources/satellite.json`
|
- `GET /proxy/tiles.goong.io/sources/satellite.json`
|
||||||
- `GET /proxy/goong/sources/base.json`
|
- `GET /proxy/tiles.goong.io/sources/base.json`
|
||||||
- `GET /proxy/goong/sources/goong.json`
|
- `GET /proxy/tiles.goong.io/sources/goong.json`
|
||||||
|
|
||||||
Nhiệm vụ:
|
Nhiệm vụ:
|
||||||
|
|
||||||
- gọi upstream Goong bằng key server-side
|
- gọi upstream Goong bằng key server-side
|
||||||
- parse JSON
|
- parse JSON
|
||||||
- rewrite `tiles[]`
|
- strip `api_key` khỏi `tiles[]`
|
||||||
|
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
|
||||||
- giữ nguyên:
|
- giữ nguyên:
|
||||||
- `bounds`
|
- `bounds`
|
||||||
- `minzoom`
|
- `minzoom`
|
||||||
@@ -154,9 +173,9 @@ Nhiệm vụ:
|
|||||||
|
|
||||||
### 5.3. Tile endpoint
|
### 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ụ:
|
Nhiệm vụ:
|
||||||
|
|
||||||
@@ -180,24 +199,25 @@ sequenceDiagram
|
|||||||
participant BE as Backend Proxy
|
participant BE as Backend Proxy
|
||||||
participant GO as Goong
|
participant GO as Goong
|
||||||
|
|
||||||
FE->>BE: GET /proxy/goong/assets/goong_satellite.json
|
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
|
||||||
FE->>BE: GET /proxy/goong/assets/goong_map_web.json
|
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
|
||||||
|
|
||||||
BE->>GO: fetch upstream style JSON
|
BE->>GO: fetch upstream style JSON
|
||||||
GO-->>BE: style JSON
|
GO-->>BE: style JSON
|
||||||
BE-->>FE: rewritten 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
|
||||||
GL->>BE: GET /proxy/goong/sources/satellite.json
|
FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
|
||||||
GL->>BE: GET /proxy/goong/sources/base.json
|
|
||||||
GL->>BE: GET /proxy/goong/sources/goong.json
|
|
||||||
|
|
||||||
BE->>GO: fetch upstream source manifests
|
BE->>GO: fetch upstream source manifests
|
||||||
GO-->>BE: 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
|
BE->>GO: fetch upstream tile
|
||||||
GO-->>BE: tile bytes
|
GO-->>BE: tile bytes
|
||||||
BE-->>GL: tile bytes
|
BE-->>GL: tile bytes
|
||||||
@@ -205,11 +225,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 7. Trade-off hiệu suất
|
## 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.
|
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
|
- 2 style JSON
|
||||||
- 3 source manifests
|
- 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:
|
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
|
### 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 CPU/memory nếu BE buffer response thay vì stream
|
||||||
- tăng load connection pool tới Goong
|
- 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
|
- MapLibre request tile/font proxy URL sẽ lỗi
|
||||||
- `api_key` vẫn có thể lộ
|
- 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à:
|
Tức là:
|
||||||
|
|
||||||
- hiệu suất tốt hơn
|
- 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
|
||||||
- nhưng mục tiêu bảo mật key không đạt
|
|
||||||
|
|
||||||
## 8. Cách giảm thiểu impact hiệu suất
|
## 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ị:
|
Khuyến nghị:
|
||||||
|
|
||||||
@@ -266,7 +285,7 @@ TTL có thể dài vì:
|
|||||||
|
|
||||||
Tối ưu:
|
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
|
### 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:
|
Nếu production có CDN/nginx/edge cache:
|
||||||
|
|
||||||
- cache mạnh cho:
|
- cache mạnh cho:
|
||||||
- rewritten style JSON
|
- sanitized style JSON
|
||||||
- rewritten source manifests
|
- sanitized source manifests
|
||||||
- tile responses
|
- 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:
|
Nên:
|
||||||
|
|
||||||
- rewrite `tiles[]` một lần ở source manifest
|
- sanitize source manifest một lần rồi cache
|
||||||
- tile route chỉ resolve path đơn giản và forward
|
- tile route chỉ resolve target path đơn giản và forward
|
||||||
|
|
||||||
Không nên:
|
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:
|
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:
|
BE cover:
|
||||||
|
|
||||||
1. style JSON
|
1. style JSON
|
||||||
2. source manifests
|
2. source manifests
|
||||||
3. tiles
|
3. tiles
|
||||||
|
4. fonts/glyphs
|
||||||
|
|
||||||
Ưu điểm:
|
Ưu điểm:
|
||||||
|
|
||||||
- key không lộ ra browser
|
- 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:
|
Nhược điểm:
|
||||||
|
|
||||||
@@ -337,7 +357,7 @@ BE cover:
|
|||||||
1. style JSON
|
1. style JSON
|
||||||
2. source manifests
|
2. source manifests
|
||||||
|
|
||||||
Nhưng không rewrite `tiles[]`
|
Nhưng để tile/font đi trực tiếp upstream.
|
||||||
|
|
||||||
Ưu điểm:
|
Ưu điểm:
|
||||||
|
|
||||||
@@ -346,28 +366,27 @@ Nhưng không rewrite `tiles[]`
|
|||||||
Nhược điểm:
|
Nhược điểm:
|
||||||
|
|
||||||
- key vẫn lộ ở tile request
|
- 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:
|
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 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
|
## 10. Recommendation cho codebase hiện tại
|
||||||
|
|
||||||
Với frontend hiện tại, hướng hợp lý nhất là:
|
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
|
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
|
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 rewrite:
|
3. để BE sanitize nested `api_key` trong style/source JSON, nhưng không rewrite nested URL thành `/proxy/...`
|
||||||
- `sources.*.url`
|
4. để BE stream tile/font response
|
||||||
- `tiles[]`
|
5. cache sanitized JSON ở BE
|
||||||
4. để BE stream tile response
|
|
||||||
5. cache rewritten JSON ở BE
|
|
||||||
|
|
||||||
Nói ngắn:
|
Nói ngắn:
|
||||||
|
|
||||||
- rewrite JSON: nên làm
|
- sanitize JSON: bắt buộc để không lộ key trong response
|
||||||
- rewrite tile URLs: bắt buộc nếu muốn giấu key
|
- FE rewrite tile URLs bằng `buildGoongProxyUrl(...)`
|
||||||
- proxy tile: phần tốn hiệu suất nhất
|
- 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
|
- 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
|
1. Tạo route proxy cho 2 style JSON
|
||||||
2. Tạo route proxy cho 3 source manifests
|
2. Tạo route proxy cho 3 source manifests
|
||||||
3. Rewrite `sources.*.url` trong style JSON
|
3. Strip `api_key` khỏi nested URL trong style JSON
|
||||||
4. Rewrite `tiles[]` trong source manifests
|
4. Strip `api_key` khỏi `tiles[]` trong source manifests
|
||||||
5. Tạo route proxy tile generic
|
5. Tạo route proxy tile generic
|
||||||
6. Stream tile response
|
6. Tạo route proxy fonts/glyphs
|
||||||
7. Preserve cache headers
|
7. Stream tile/font response
|
||||||
8. Cache rewritten JSON
|
8. Preserve cache headers
|
||||||
9. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
|
9. Cache sanitized JSON
|
||||||
|
10. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
|
||||||
|
|||||||
+31
-22
@@ -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
|
## 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`
|
Background thật được thêm sau khi map load:
|
||||||
- `land`
|
|
||||||
- `bg-countries-fill`
|
- `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-country-borders-line`
|
||||||
|
- `bg-province-borders-line`
|
||||||
|
- `bg-district-borders-line`
|
||||||
- `country-labels`
|
- `country-labels`
|
||||||
- `regions-line`
|
|
||||||
- `lakes-fill`
|
|
||||||
- `rivers-line`
|
- `rivers-line`
|
||||||
- `geolines-line`
|
|
||||||
|
|
||||||
Visibility của các layer này đi qua `BackgroundLayerVisibility`.
|
Visibility của các nhóm này đi qua `BackgroundLayerVisibility`.
|
||||||
|
|
||||||
## 3. Sources mà editor đang dùng
|
## 3. Sources mà editor đang dùng
|
||||||
|
|
||||||
@@ -85,17 +87,20 @@ Source này dùng cho:
|
|||||||
|
|
||||||
`useMapSync()` chịu trách nhiệm:
|
`useMapSync()` chịu trách nhiệm:
|
||||||
|
|
||||||
1. filter draft theo binding nếu `respectBindingFilter = true`
|
1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước
|
||||||
2. filter theo geometry visibility
|
2. filter draft theo binding nếu `applyGeometryBindingFilter = true`
|
||||||
3. split feature thành nhóm polygon/line/point
|
3. filter theo geometry visibility
|
||||||
4. decorate line/polygon/point cho label rendering
|
4. split feature thành nhóm polygon/line/point
|
||||||
5. build source riêng cho path arrows
|
5. decorate line/polygon/point cho label rendering
|
||||||
6. set selected feature state
|
6. build source riêng cho path arrows
|
||||||
|
7. set selected feature state
|
||||||
|
|
||||||
Điểm quan trọng:
|
Điểm quan trọng:
|
||||||
|
|
||||||
- data mà map nhận không phải raw `draft` nguyên xi
|
- data mà map render không phải raw `mainDraft` nguyên xi
|
||||||
- nó là `draft` sau khi đã qua visibility, binding filter và label decoration
|
- `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
|
## 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`.
|
`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ể
|
## 6. Các engine cụ thể
|
||||||
|
|
||||||
### `initDrawing`
|
### `initDrawing`
|
||||||
@@ -153,11 +160,12 @@ Binding hiện tại:
|
|||||||
- bắt đầu edit geometry
|
- bắt đầu edit geometry
|
||||||
- chuyển sang `replay`
|
- chuyển sang `replay`
|
||||||
|
|
||||||
`replay` hiện không phải cinematic replay đầy đủ.
|
Trong map interaction, `replay` vẫn dùng `initSelect`; `replay_preview` không cho edit/select theo engine.
|
||||||
Nó là mode hiển thị tập trung vào một geometry:
|
Phần script/preview replay nằm ở sidebar và preview overlay:
|
||||||
|
|
||||||
- có nút thoát replay
|
- map render `replayDraft` hydrate từ `target_geometry_ids`
|
||||||
- có thể ẩn geometry ngoài danh sách `binding`
|
- 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
|
## 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
|
## 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
|
- 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
|
- `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`
|
- 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
|
### 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
|
### 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`:
|
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
|
||||||
|
|
||||||
- `raster-base-layer`
|
- `raster-base-layer`
|
||||||
- `graticules-line`
|
|
||||||
- `land`
|
|
||||||
- `bg-countries-fill`
|
|
||||||
- `bg-country-borders-line`
|
- `bg-country-borders-line`
|
||||||
|
- `bg-province-borders-line`
|
||||||
|
- `bg-district-borders-line`
|
||||||
- `country-labels`
|
- `country-labels`
|
||||||
- `regions-line`
|
|
||||||
- `lakes-fill`
|
|
||||||
- `rivers-line`
|
- `rivers-line`
|
||||||
- `geolines-line`
|
|
||||||
|
|
||||||
Lưu ý:
|
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`
|
- `BackgroundLayersPanel` chỉ biết toggle theo `id`
|
||||||
|
|
||||||
Visibility mặc định:
|
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`
|
- được persist bằng `uhm.backgroundLayerVisibility.v1`
|
||||||
|
|
||||||
## 3. Geotype registry
|
## 3. Geotype registry
|
||||||
@@ -77,7 +76,7 @@ Các type đang được register:
|
|||||||
- `port`
|
- `port`
|
||||||
- `bridge`
|
- `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
|
## 4. Type matching
|
||||||
|
|
||||||
@@ -119,6 +118,8 @@ Point geotype dùng icon pipeline trong:
|
|||||||
- `shared/pointStyle.ts`
|
- `shared/pointStyle.ts`
|
||||||
- `ensurePointGeotypeIcons(map)`
|
- `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 đó.
|
Đ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
|
## 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.
|
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
|
## 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:
|
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
|
### Bước 1: load baseline
|
||||||
|
|
||||||
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
|
- `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
|
- `useEditorState()` reset draft và undo
|
||||||
|
|
||||||
### Bước 2: chỉnh sửa cục bộ
|
### Bước 2: chỉnh sửa cục bộ
|
||||||
@@ -71,6 +71,7 @@ User có thể sửa:
|
|||||||
- entity snapshot
|
- entity snapshot
|
||||||
- wiki snapshot
|
- wiki snapshot
|
||||||
- entity-wiki snapshot
|
- entity-wiki snapshot
|
||||||
|
- replay script
|
||||||
|
|
||||||
Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
|
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
|
- đã mở được project
|
||||||
- `pendingSaveCount > 0`
|
- `pendingSaveCount > 0`
|
||||||
|
- không còn orphan geometry
|
||||||
|
|
||||||
Luồng commit:
|
Luồng commit:
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ Luồng commit:
|
|||||||
- refresh `projectState`
|
- refresh `projectState`
|
||||||
- refresh `sectionCommits`
|
- refresh `sectionCommits`
|
||||||
- cập nhật `baselineSnapshot`
|
- cập nhật `baselineSnapshot`
|
||||||
- set `initialData = editor.draft`
|
- set `baselineFeatureCollection = editor.mainDraft`
|
||||||
- `editor.clearChanges()`
|
- `editor.clearChanges()`
|
||||||
- clear `commitTitle`
|
- clear `commitTitle`
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ Luồng commit:
|
|||||||
- project đang mở
|
- project đang mở
|
||||||
- có `head_commit_id`
|
- có `head_commit_id`
|
||||||
- `pendingSaveCount === 0`
|
- `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.
|
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:
|
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
|
- tải commit list mới nhất
|
||||||
- lấy snapshot của commit được chọn
|
- lấy snapshot của commit được chọn
|
||||||
- normalize snapshot
|
- 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`
|
- `draft`
|
||||||
- `changes`
|
- `changes`
|
||||||
- `snapshotEntities`
|
- `snapshotEntityRows`
|
||||||
- `snapshotWikis`
|
- `snapshotWikis`
|
||||||
- `snapshotEntityWikiLinks`
|
- `snapshotEntityWikiLinks`
|
||||||
|
- `effectiveReplays`
|
||||||
- `previousSnapshot`
|
- `previousSnapshot`
|
||||||
|
|
||||||
và sinh ra:
|
và sinh ra:
|
||||||
@@ -141,12 +146,14 @@ và sinh ra:
|
|||||||
- `geometry_entity`
|
- `geometry_entity`
|
||||||
- `wikis`
|
- `wikis`
|
||||||
- `entity_wiki`
|
- `entity_wiki`
|
||||||
|
- `replays`
|
||||||
|
|
||||||
Các điểm quan trọng:
|
Các điểm quan trọng:
|
||||||
|
|
||||||
- geometry many-to-many với entity được persist ở `geometry_entity[]`
|
- 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
|
- 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
|
- 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
|
## 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 entity dirty
|
||||||
- cộng thêm 1 nếu wiki 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 entity-wiki dirty
|
||||||
|
- cộng thêm 1 nếu replay dirty
|
||||||
|
|
||||||
Vì vậy:
|
Vì vậy:
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Quy ước operation:
|
|||||||
- wiki ref thêm từ search: `source: "ref"`, `operation: "reference"`
|
- 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 đã 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
|
- 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
|
## 4. Slug
|
||||||
|
|
||||||
@@ -177,4 +178,5 @@ Hiện tại chưa có:
|
|||||||
- schema block editor mới cho project wiki
|
- schema block editor mới cho project wiki
|
||||||
- cross-project link graph UI
|
- 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 =
|
export type UndoAction =
|
||||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||||
| { type: "delete"; feature: Feature }
|
| { type: "delete"; feature: Feature; index?: number }
|
||||||
| { type: "create"; id: FeatureProperties["id"] }
|
| { type: "create"; id: FeatureProperties["id"] }
|
||||||
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
|
| { 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 }
|
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
|
||||||
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
||||||
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
| { 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 type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
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.
|
// 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.
|
// 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 commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
const cloned = deepClone(nextDraft);
|
const cloned = deepClone(nextDraft);
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
|||||||
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
|
&& 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": {
|
case "replay_session": {
|
||||||
const next = b as Extract<UndoAction, { type: "replay_session" }>;
|
const next = b as Extract<UndoAction, { type: "replay_session" }>;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
openSectionEditor,
|
openSectionEditor,
|
||||||
submitSection,
|
submitSection,
|
||||||
} from "@/uhm/api/projects";
|
} 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 { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
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.
|
// operations should not carry over as deltas into the next commit.
|
||||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||||
const commits = await fetchProjectCommits(projectId);
|
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.setActiveSection(editorPayload.project);
|
||||||
state.setSelectedProjectId(editorPayload.project.id);
|
state.setSelectedProjectId(editorPayload.project.id);
|
||||||
state.setProjectState(editorPayload.state);
|
state.setProjectState(editorPayload.state);
|
||||||
state.setBaselineSnapshot(sessionSnapshot);
|
state.setBaselineSnapshot(sessionSnapshot);
|
||||||
state.setInitialData(nextInitialData);
|
state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
|
||||||
state.setProjectCommits(commits);
|
state.setProjectCommits(commits);
|
||||||
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
|
||||||
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
state.setSelectedFeatureIds([]);
|
state.setSelectedFeatureIds([]);
|
||||||
@@ -68,6 +74,15 @@ export function useProjectCommands(options: Options) {
|
|||||||
return;
|
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();
|
const geometryChanges = options.editor.buildPayload();
|
||||||
state.setIsSaving(true);
|
state.setIsSaving(true);
|
||||||
state.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
@@ -76,7 +91,7 @@ export function useProjectCommands(options: Options) {
|
|||||||
project: state.activeSection,
|
project: state.activeSection,
|
||||||
draft: options.editor.mainDraft,
|
draft: options.editor.mainDraft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
snapshotEntities: state.snapshotEntities,
|
snapshotEntityRows: state.snapshotEntityRows,
|
||||||
snapshotWikis: state.snapshotWikis,
|
snapshotWikis: state.snapshotWikis,
|
||||||
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
||||||
replays: options.editor.effectiveReplays,
|
replays: options.editor.effectiveReplays,
|
||||||
@@ -111,10 +126,10 @@ export function useProjectCommands(options: Options) {
|
|||||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||||
state.setProjectState(result.state);
|
state.setProjectState(result.state);
|
||||||
state.setBaselineSnapshot(sessionSnapshot);
|
state.setBaselineSnapshot(sessionSnapshot);
|
||||||
state.setSnapshotEntities(sessionSnapshot.entities || []);
|
state.setSnapshotEntityRows(sessionSnapshot.entities || []);
|
||||||
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||||
state.setInitialData(options.editor.mainDraft);
|
state.setBaselineFeatureCollection(options.editor.mainDraft);
|
||||||
options.editor.clearChanges();
|
options.editor.clearChanges();
|
||||||
state.setCommitTitle("");
|
state.setCommitTitle("");
|
||||||
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
||||||
@@ -206,6 +221,15 @@ export function useProjectCommands(options: Options) {
|
|||||||
return;
|
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.setIsSubmitting(true);
|
||||||
state.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
@@ -220,7 +244,7 @@ export function useProjectCommands(options: Options) {
|
|||||||
} finally {
|
} finally {
|
||||||
state.setIsSubmitting(false);
|
state.setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [options.pendingSaveCount, options.store]);
|
}, [options.editor.mainDraft, options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
const restoreCommit = useCallback(async (commitId: string) => {
|
const restoreCommit = useCallback(async (commitId: string) => {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
@@ -247,11 +271,11 @@ export function useProjectCommands(options: Options) {
|
|||||||
|
|
||||||
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
||||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
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.setBaselineSnapshot(sessionSnapshot);
|
||||||
state.setInitialData(nextInitialData);
|
state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
|
||||||
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
|
||||||
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
state.setSelectedFeatureIds([]);
|
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 {
|
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
@@ -311,8 +363,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
|||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: typeof e.name === "string" ? e.name : undefined,
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
description: typeof e.description === "string" ? e.description : e.description ?? null,
|
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_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
|
||||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
|
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -333,8 +385,8 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
|
|||||||
draw_geometry: g.draw_geometry,
|
draw_geometry: g.draw_geometry,
|
||||||
geometry: g.geometry,
|
geometry: g.geometry,
|
||||||
binding: Array.isArray(g.binding) ? [...g.binding] : undefined,
|
binding: Array.isArray(g.binding) ? [...g.binding] : undefined,
|
||||||
time_start: typeof g.time_start === "number" ? g.time_start : g.time_start ?? undefined,
|
time_start: normalizeTimelineYearValue(g.time_start) ?? undefined,
|
||||||
time_end: typeof g.time_end === "number" ? g.time_end : g.time_end ?? undefined,
|
time_end: normalizeTimelineYearValue(g.time_end) ?? undefined,
|
||||||
bbox: g.bbox
|
bbox: g.bbox
|
||||||
? {
|
? {
|
||||||
min_lng: g.bbox.min_lng,
|
min_lng: g.bbox.min_lng,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function useEntitySessionState() {
|
|||||||
// Entity catalog loaded from backend (global list, used for search/lookup).
|
// Entity catalog loaded from backend (global list, used for search/lookup).
|
||||||
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
||||||
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
|
// 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.
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
// Features đang được chọn để thao tác bind entities/metadata.
|
// Features đang được chọn để thao tác bind entities/metadata.
|
||||||
@@ -48,8 +48,8 @@ export function useEntitySessionState() {
|
|||||||
return {
|
return {
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
setEntityCatalog,
|
setEntityCatalog,
|
||||||
snapshotEntities,
|
snapshotEntityRows,
|
||||||
setSnapshotEntities,
|
setSnapshotEntityRows,
|
||||||
entityStatus,
|
entityStatus,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||||
import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
|
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 { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { EntitySnapshotOperation } 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 : "";
|
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 {
|
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||||
if (!isRecord(raw)) return null;
|
if (!isRecord(raw)) return null;
|
||||||
const snapshot = raw as UnknownRecord;
|
const snapshot = raw as UnknownRecord;
|
||||||
@@ -126,8 +132,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
operation,
|
operation,
|
||||||
name: typeof e.name === "string" ? e.name : undefined,
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : 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_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
|
||||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
|
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -156,8 +162,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
|
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
|
||||||
geometry: row.geometry as GeometrySnapshot["geometry"],
|
geometry: row.geometry as GeometrySnapshot["geometry"],
|
||||||
binding: Array.isArray(row.binding) ? row.binding as string[] : undefined,
|
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_start: normalizeTimelineYearValue(row.time_start) ?? undefined,
|
||||||
time_end: typeof row.time_end === "number" ? row.time_end : row.time_end == null ? undefined : undefined,
|
time_end: normalizeTimelineYearValue(row.time_end) ?? undefined,
|
||||||
bbox: isRecord(row.bbox)
|
bbox: isRecord(row.bbox)
|
||||||
? {
|
? {
|
||||||
min_lng: Number(row.bbox.min_lng),
|
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() : "";
|
const name = typeof row.name === "string" ? String(row.name).trim() : "";
|
||||||
if (name) entityNameById.set(id, name);
|
if (name) entityNameById.set(id, name);
|
||||||
entityTimeById.set(id, {
|
entityTimeById.set(id, {
|
||||||
time_start: typeof row.time_start === "number" ? row.time_start : null,
|
time_start: normalizeTimelineYearValue(row.time_start),
|
||||||
time_end: typeof row.time_end === "number" ? row.time_end : null,
|
time_end: normalizeTimelineYearValue(row.time_end),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const geometryById = new Map<string, GeometrySnapshot>();
|
const geometryById = new Map<string, GeometrySnapshot>();
|
||||||
@@ -293,6 +299,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
const gid = String(feature.properties.id);
|
const gid = String(feature.properties.id);
|
||||||
const entity_ids = byGeom.get(gid) || [];
|
const entity_ids = byGeom.get(gid) || [];
|
||||||
const p = feature.properties as unknown as UnknownRecord;
|
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 existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
|
||||||
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
|
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
|
||||||
@@ -334,8 +352,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
|| fallbackTypeKey;
|
|| fallbackTypeKey;
|
||||||
if (typeKey) p.type = typeKey;
|
if (typeKey) p.type = typeKey;
|
||||||
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
||||||
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
|
const timeStart = normalizeTimelineYearValue(geo.time_start);
|
||||||
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
|
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) {
|
} else if (!existingTypeKey) {
|
||||||
p.type = fallbackTypeKey;
|
p.type = fallbackTypeKey;
|
||||||
}
|
}
|
||||||
@@ -359,7 +387,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
project: Project;
|
project: Project;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
changes: Change[];
|
changes: Change[];
|
||||||
snapshotEntities: EntitySnapshot[];
|
snapshotEntityRows: EntitySnapshot[];
|
||||||
snapshotWikis: WikiSnapshot[];
|
snapshotWikis: WikiSnapshot[];
|
||||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
replays: BattleReplay[];
|
replays: BattleReplay[];
|
||||||
@@ -410,11 +438,11 @@ export function buildEditorSnapshot(options: {
|
|||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: typeof cloned.name === "string" ? cloned.name : undefined,
|
name: typeof cloned.name === "string" ? cloned.name : undefined,
|
||||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
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_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
|
||||||
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
|
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of options.snapshotEntities || []) {
|
for (const row of options.snapshotEntityRows || []) {
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
@@ -435,8 +463,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
name,
|
name,
|
||||||
operation,
|
operation,
|
||||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
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_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
|
||||||
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
|
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +511,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
: "reference";
|
: "reference";
|
||||||
const bbox = getFeatureBBox(feature);
|
const bbox = getFeatureBBox(feature);
|
||||||
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
|
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
|
||||||
|
const timeStart = normalizeTimelineYearValue(feature.properties.time_start);
|
||||||
|
const timeEnd = normalizeTimelineYearValue(feature.properties.time_end);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
operation,
|
operation,
|
||||||
@@ -490,8 +520,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
type: typeKey,
|
type: typeKey,
|
||||||
draw_geometry: feature.geometry,
|
draw_geometry: feature.geometry,
|
||||||
binding: normalizeFeatureBindingIds(feature),
|
binding: normalizeFeatureBindingIds(feature),
|
||||||
time_start: feature.properties.time_start ?? null,
|
time_start: timeStart,
|
||||||
time_end: feature.properties.time_end ?? null,
|
time_end: timeEnd,
|
||||||
bbox: bbox
|
bbox: bbox
|
||||||
? {
|
? {
|
||||||
min_lng: bbox.minLng,
|
min_lng: bbox.minLng,
|
||||||
@@ -689,8 +719,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
operation: e.operation,
|
operation: e.operation,
|
||||||
name: typeof e.name === "string" ? e.name : undefined,
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
|
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_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
|
||||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
|
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
geometries: geometries.slice().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 {
|
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||||
const cloned = JSON.parse(JSON.stringify(snapshot)) as 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)) {
|
if (Array.isArray(cloned.geometries)) {
|
||||||
cloned.geometries = cloned.geometries.map((geometry) => {
|
cloned.geometries = cloned.geometries.map((geometry) => {
|
||||||
const row = { ...(geometry as unknown as UnknownRecord) };
|
const row = { ...(geometry as unknown as UnknownRecord) };
|
||||||
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
|
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
|
||||||
delete row.geo_type;
|
delete row.geo_type;
|
||||||
|
normalizeApiTimeFields(row);
|
||||||
|
|
||||||
if (typeKey) {
|
if (typeKey) {
|
||||||
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||||
@@ -846,6 +896,7 @@ function normalizeReplayUiOption(value: unknown): UIOptionName | null {
|
|||||||
case "timeline":
|
case "timeline":
|
||||||
case "layer_panel":
|
case "layer_panel":
|
||||||
case "wiki_panel":
|
case "wiki_panel":
|
||||||
|
case "close_wiki_panel":
|
||||||
case "zoom_panel":
|
case "zoom_panel":
|
||||||
case "wiki":
|
case "wiki":
|
||||||
case "toast":
|
case "toast":
|
||||||
@@ -910,6 +961,7 @@ function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null
|
|||||||
case "toggle_labels":
|
case "toggle_labels":
|
||||||
case "show_labels":
|
case "show_labels":
|
||||||
case "hide_labels":
|
case "hide_labels":
|
||||||
|
case "show_all_geometries":
|
||||||
case "reset_camera_north":
|
case "reset_camera_north":
|
||||||
return value;
|
return value;
|
||||||
default:
|
default:
|
||||||
@@ -958,10 +1010,15 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
|
|||||||
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
|
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case "set_title":
|
case "set_title":
|
||||||
|
case "clear_title":
|
||||||
case "set_descriptions":
|
case "set_descriptions":
|
||||||
|
case "clear_descriptions":
|
||||||
case "show_dialog_box":
|
case "show_dialog_box":
|
||||||
|
case "clear_dialog_box":
|
||||||
case "display_historical_image":
|
case "display_historical_image":
|
||||||
|
case "clear_historical_image":
|
||||||
case "set_step_subtitle":
|
case "set_step_subtitle":
|
||||||
|
case "clear_step_subtitle":
|
||||||
return value;
|
return value;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ type Options = {
|
|||||||
export function useEditorSessionState(options: Options) {
|
export function useEditorSessionState(options: Options) {
|
||||||
// Mode thao tác map/editor hiện tại.
|
// Mode thao tác map/editor hiện tại.
|
||||||
const [mode, setMode] = useState<EditorMode>("idle");
|
const [mode, setMode] = useState<EditorMode>("idle");
|
||||||
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot).
|
// Baseline FeatureCollection used to seed/reset the editor draft for the current session.
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
const [baselineFeatureCollection, setBaselineFeatureCollection] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||||
|
|
||||||
const project = useProjectSessionState({
|
const project = useProjectSessionState({
|
||||||
defaultEditorUserId: options.defaultEditorUserId,
|
defaultEditorUserId: options.defaultEditorUserId,
|
||||||
@@ -41,8 +41,8 @@ export function useEditorSessionState(options: Options) {
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
initialData,
|
baselineFeatureCollection,
|
||||||
setInitialData,
|
setBaselineFeatureCollection,
|
||||||
...project,
|
...project,
|
||||||
...entity,
|
...entity,
|
||||||
...timeline,
|
...timeline,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/
|
|||||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
type SnapshotUndoApi = {
|
type SnapshotUndoApi = {
|
||||||
snapshotEntitiesRef: { current: EntitySnapshot[] };
|
snapshotEntityRowsRef: { current: EntitySnapshot[] };
|
||||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
setSnapshotEntityRows: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
snapshotWikisRef: { current: WikiSnapshot[] };
|
snapshotWikisRef: { current: WikiSnapshot[] };
|
||||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
|
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
|
||||||
@@ -41,7 +41,7 @@ type ReplayDraftSyncMode = "none" | "reset";
|
|||||||
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
|
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
|
||||||
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
|
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
|
||||||
export function useEditorState(
|
export function useEditorState(
|
||||||
initialData: FeatureCollection,
|
baselineFeatureCollection: FeatureCollection,
|
||||||
options: {
|
options: {
|
||||||
snapshotUndo?: SnapshotUndoApi;
|
snapshotUndo?: SnapshotUndoApi;
|
||||||
initialReplays?: BattleReplay[];
|
initialReplays?: BattleReplay[];
|
||||||
@@ -50,7 +50,7 @@ export function useEditorState(
|
|||||||
) {
|
) {
|
||||||
const { snapshotUndo, initialReplays, mode } = options;
|
const { snapshotUndo, initialReplays, mode } = options;
|
||||||
|
|
||||||
const mainDraftState = useDraftState(initialData);
|
const mainDraftState = useDraftState(baselineFeatureCollection);
|
||||||
const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
|
const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
|
||||||
const {
|
const {
|
||||||
draft: mainDraft,
|
draft: mainDraft,
|
||||||
@@ -116,7 +116,7 @@ export function useEditorState(
|
|||||||
|
|
||||||
// Map baseline (id -> feature) để diff main draft ra changes.
|
// Map baseline (id -> feature) để diff main draft ra changes.
|
||||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
buildInitialMap(initialData)
|
buildInitialMap(baselineFeatureCollection)
|
||||||
);
|
);
|
||||||
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
@@ -132,22 +132,27 @@ export function useEditorState(
|
|||||||
targetCommitDraft({
|
targetCommitDraft({
|
||||||
...targetDraftRef.current,
|
...targetDraftRef.current,
|
||||||
features: targetDraftRef.current.features.filter((feature) =>
|
features: targetDraftRef.current.features.filter((feature) =>
|
||||||
feature.properties.id !== action.id
|
!featureIdEquals(feature.properties.id, action.id)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "delete": {
|
case "delete": {
|
||||||
const feature = deepClone(action.feature);
|
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({
|
targetCommitDraft({
|
||||||
...targetDraftRef.current,
|
...targetDraftRef.current,
|
||||||
features: [...targetDraftRef.current.features, feature],
|
features: nextFeatures,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
||||||
feature.properties.id === action.id
|
featureIdEquals(feature.properties.id, action.id)
|
||||||
);
|
);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
const nextFeatures = [...targetDraftRef.current.features];
|
const nextFeatures = [...targetDraftRef.current.features];
|
||||||
@@ -160,7 +165,7 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
case "properties": {
|
case "properties": {
|
||||||
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
||||||
feature.properties.id === action.id
|
featureIdEquals(feature.properties.id, action.id)
|
||||||
);
|
);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
const nextFeatures = [...targetDraftRef.current.features];
|
const nextFeatures = [...targetDraftRef.current.features];
|
||||||
@@ -174,8 +179,8 @@ export function useEditorState(
|
|||||||
case "snapshot_entities": {
|
case "snapshot_entities": {
|
||||||
if (!allowSnapshotUndo || !snapshotUndo) return false;
|
if (!allowSnapshotUndo || !snapshotUndo) return false;
|
||||||
const prev = deepClone(action.prev);
|
const prev = deepClone(action.prev);
|
||||||
snapshotUndo.snapshotEntitiesRef.current = prev;
|
snapshotUndo.snapshotEntityRowsRef.current = prev;
|
||||||
snapshotUndo.setSnapshotEntities(prev);
|
snapshotUndo.setSnapshotEntityRows(prev);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "snapshot_wikis": {
|
case "snapshot_wikis": {
|
||||||
@@ -226,6 +231,19 @@ export function useEditorState(
|
|||||||
return true;
|
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(
|
return applyUndoActionToDraft(
|
||||||
action,
|
action,
|
||||||
mainDraftRef,
|
mainDraftRef,
|
||||||
@@ -264,7 +282,7 @@ export function useEditorState(
|
|||||||
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
|
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetMainDraft(deepClone(initialData));
|
resetMainDraft(deepClone(baselineFeatureCollection));
|
||||||
resetReplayDraft(EMPTY_FEATURE_COLLECTION);
|
resetReplayDraft(EMPTY_FEATURE_COLLECTION);
|
||||||
updateReplaysState(initialReplays || []);
|
updateReplaysState(initialReplays || []);
|
||||||
setActiveReplayId(null);
|
setActiveReplayId(null);
|
||||||
@@ -273,12 +291,12 @@ export function useEditorState(
|
|||||||
activeReplaySeedRef.current = null;
|
activeReplaySeedRef.current = null;
|
||||||
clearMainUndo();
|
clearMainUndo();
|
||||||
clearReplayUndo();
|
clearReplayUndo();
|
||||||
initialMapRef.current = buildInitialMap(initialData);
|
initialMapRef.current = buildInitialMap(baselineFeatureCollection);
|
||||||
setBaselineVersion((version) => version + 1);
|
setBaselineVersion((version) => version + 1);
|
||||||
}, [
|
}, [
|
||||||
clearMainUndo,
|
clearMainUndo,
|
||||||
clearReplayUndo,
|
clearReplayUndo,
|
||||||
initialData,
|
baselineFeatureCollection,
|
||||||
initialReplays,
|
initialReplays,
|
||||||
resetMainDraft,
|
resetMainDraft,
|
||||||
resetReplayDraft,
|
resetReplayDraft,
|
||||||
@@ -371,7 +389,7 @@ export function useEditorState(
|
|||||||
pushMainUndo({ type: "create", id: featureClone.properties.id });
|
pushMainUndo({ type: "create", id: featureClone.properties.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeatureWithSnapshotEntities(
|
function createFeatureWithSnapshotEntityRows(
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
nextEntities: SetStateAction<EntitySnapshot[]>,
|
nextEntities: SetStateAction<EntitySnapshot[]>,
|
||||||
label = "Import geometry"
|
label = "Import geometry"
|
||||||
@@ -384,7 +402,7 @@ export function useEditorState(
|
|||||||
const undoActions: UndoAction[] = [];
|
const undoActions: UndoAction[] = [];
|
||||||
|
|
||||||
if (snapshotUndo) {
|
if (snapshotUndo) {
|
||||||
const prevEntities = snapshotUndo.snapshotEntitiesRef.current || [];
|
const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || [];
|
||||||
const prevEntitiesClone = deepClone(prevEntities);
|
const prevEntitiesClone = deepClone(prevEntities);
|
||||||
const computedEntities = typeof nextEntities === "function"
|
const computedEntities = typeof nextEntities === "function"
|
||||||
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
|
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
|
||||||
@@ -403,8 +421,8 @@ export function useEditorState(
|
|||||||
label: "Cập nhật entities",
|
label: "Cập nhật entities",
|
||||||
prev: prevEntitiesClone,
|
prev: prevEntitiesClone,
|
||||||
});
|
});
|
||||||
snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone;
|
snapshotUndo.snapshotEntityRowsRef.current = computedEntitiesClone;
|
||||||
snapshotUndo.setSnapshotEntities(computedEntitiesClone);
|
snapshotUndo.setSnapshotEntityRows(computedEntitiesClone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +446,7 @@ export function useEditorState(
|
|||||||
return;
|
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;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const nextFeatures = [...mainDraftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
@@ -472,7 +490,7 @@ export function useEditorState(
|
|||||||
const undoActions: UndoAction[] = [];
|
const undoActions: UndoAction[] = [];
|
||||||
|
|
||||||
for (const [id, patch] of mergedPatches.entries()) {
|
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;
|
if (idx === -1) continue;
|
||||||
|
|
||||||
const prevProperties = deepClone(nextFeatures[idx].properties);
|
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||||
@@ -506,7 +524,7 @@ export function useEditorState(
|
|||||||
return;
|
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;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const prevFeature = mainDraftRef.current.features[idx];
|
const prevFeature = mainDraftRef.current.features[idx];
|
||||||
@@ -529,14 +547,22 @@ export function useEditorState(
|
|||||||
return;
|
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;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const feature = mainDraftRef.current.features[idx];
|
const feature = mainDraftRef.current.features[idx];
|
||||||
const nextFeatures = [...mainDraftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
nextFeatures.splice(idx, 1);
|
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 });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,24 +575,49 @@ export function useEditorState(
|
|||||||
const nextFeatures: Feature[] = [];
|
const nextFeatures: Feature[] = [];
|
||||||
const undoActions: UndoAction[] = [];
|
const undoActions: UndoAction[] = [];
|
||||||
|
|
||||||
for (const feature of mainDraftRef.current.features) {
|
mainDraftRef.current.features.forEach((feature, index) => {
|
||||||
if (idsSet.has(String(feature.properties.id))) {
|
if (idsSet.has(String(feature.properties.id))) {
|
||||||
undoActions.push({ type: "delete", feature: deepClone(feature) });
|
undoActions.push({ type: "delete", feature: deepClone(feature), index });
|
||||||
} else {
|
} else {
|
||||||
nextFeatures.push(feature);
|
nextFeatures.push(feature);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (undoActions.length === 0) return;
|
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(
|
pushMainUndo(
|
||||||
undoActions.length === 1
|
groupedActions.length === 1
|
||||||
? undoActions[0]
|
? groupedActions[0]
|
||||||
: { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions }
|
: { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: groupedActions }
|
||||||
);
|
);
|
||||||
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
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[] {
|
function buildPayload(): Change[] {
|
||||||
return Array.from(changes.values()).map((change) => deepClone(change));
|
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||||
}
|
}
|
||||||
@@ -620,12 +671,12 @@ export function useEditorState(
|
|||||||
clearReplayUndo();
|
clearReplayUndo();
|
||||||
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
|
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
|
||||||
|
|
||||||
const setSnapshotEntitiesUndoable = useCallback((
|
const setSnapshotEntityRowsUndoable = useCallback((
|
||||||
next: SetStateAction<EntitySnapshot[]>,
|
next: SetStateAction<EntitySnapshot[]>,
|
||||||
label = "Cập nhật entities"
|
label = "Cập nhật entities"
|
||||||
) => {
|
) => {
|
||||||
if (!snapshotUndo) return;
|
if (!snapshotUndo) return;
|
||||||
const prev = snapshotUndo.snapshotEntitiesRef.current || [];
|
const prev = snapshotUndo.snapshotEntityRowsRef.current || [];
|
||||||
const prevClone = deepClone(prev);
|
const prevClone = deepClone(prev);
|
||||||
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
|
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
|
||||||
let changed = true;
|
let changed = true;
|
||||||
@@ -638,8 +689,8 @@ export function useEditorState(
|
|||||||
|
|
||||||
const computedClone = deepClone(computed);
|
const computedClone = deepClone(computed);
|
||||||
pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
|
pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
|
||||||
snapshotUndo.snapshotEntitiesRef.current = computedClone;
|
snapshotUndo.snapshotEntityRowsRef.current = computedClone;
|
||||||
snapshotUndo.setSnapshotEntities(computedClone);
|
snapshotUndo.setSnapshotEntityRows(computedClone);
|
||||||
}, [pushMainUndo, snapshotUndo]);
|
}, [pushMainUndo, snapshotUndo]);
|
||||||
|
|
||||||
const setSnapshotWikisUndoable = useCallback((
|
const setSnapshotWikisUndoable = useCallback((
|
||||||
@@ -688,6 +739,54 @@ export function useEditorState(
|
|||||||
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
|
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
|
||||||
}, [pushMainUndo, snapshotUndo]);
|
}, [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(() => {
|
const undo = useCallback(() => {
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
undoReplay();
|
undoReplay();
|
||||||
@@ -717,7 +816,7 @@ export function useEditorState(
|
|||||||
changeCount,
|
changeCount,
|
||||||
canUndoReplay: replayUndoStack.length > 0,
|
canUndoReplay: replayUndoStack.length > 0,
|
||||||
createFeature,
|
createFeature,
|
||||||
createFeatureWithSnapshotEntities,
|
createFeatureWithSnapshotEntityRows,
|
||||||
patchFeatureProperties,
|
patchFeatureProperties,
|
||||||
patchFeaturePropertiesBatch,
|
patchFeaturePropertiesBatch,
|
||||||
updateFeature,
|
updateFeature,
|
||||||
@@ -728,9 +827,10 @@ export function useEditorState(
|
|||||||
clearChanges,
|
clearChanges,
|
||||||
hasPersistedFeature,
|
hasPersistedFeature,
|
||||||
// Snapshot undo helpers (no-op if snapshotUndo not provided)
|
// Snapshot undo helpers (no-op if snapshotUndo not provided)
|
||||||
setSnapshotEntities: setSnapshotEntitiesUndoable,
|
setSnapshotEntityRows: setSnapshotEntityRowsUndoable,
|
||||||
setSnapshotWikis: setSnapshotWikisUndoable,
|
setSnapshotWikis: setSnapshotWikisUndoable,
|
||||||
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||||
|
setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,6 +838,18 @@ function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
|
|||||||
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
|
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(
|
function createReplaySessionSeed(
|
||||||
sourceDraft: FeatureCollection,
|
sourceDraft: FeatureCollection,
|
||||||
geometryId: string,
|
geometryId: string,
|
||||||
@@ -888,6 +1000,40 @@ function replaceReplayByGeometryId(
|
|||||||
return next;
|
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) {
|
function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
|
||||||
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
|
||||||
|
|
||||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
|
||||||
export function initSelect(
|
|
||||||
map: maplibregl.Map,
|
|
||||||
getMode: ModeGetter,
|
|
||||||
onDelete?: (id: 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,
|
|
||||||
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
|
|
||||||
) {
|
|
||||||
|
|
||||||
const FEATURE_STATE_SOURCES = [
|
|
||||||
"countries",
|
|
||||||
"places",
|
|
||||||
"path-arrow-shapes",
|
|
||||||
] as const;
|
|
||||||
const selectedIds = new Set<number | string>();
|
|
||||||
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
|
|
||||||
let contextMenu: HTMLDivElement | null = null;
|
|
||||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
|
||||||
|
|
||||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
|
||||||
function clearSelection(emit = true) {
|
|
||||||
if (!selectedIds.size) return;
|
|
||||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
|
||||||
selectedIds.clear();
|
|
||||||
if (emit) {
|
|
||||||
onSelectIds?.([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
|
||||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
|
||||||
const id = feature.id ?? feature.properties?.id;
|
|
||||||
if (id === undefined || id === null) return;
|
|
||||||
|
|
||||||
if (!additive) {
|
|
||||||
clearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
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(idToRemove, false);
|
|
||||||
selectedIds.delete(idToRemove);
|
|
||||||
onSelectIds?.(Array.from(selectedIds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectionStateForId(id, true);
|
|
||||||
selectedIds.add(id);
|
|
||||||
onSelectIds?.(Array.from(selectedIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
|
||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
|
||||||
if (getMode() !== "select" && getMode() !== "replay") return;
|
|
||||||
if (isEditSessionActive?.()) return;
|
|
||||||
const selectableLayers = getSelectableLayers();
|
|
||||||
if (!selectableLayers.length) return;
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
|
||||||
layers: selectableLayers,
|
|
||||||
}) as maplibregl.MapGeoJSONFeature[];
|
|
||||||
|
|
||||||
if (!features.length) {
|
|
||||||
clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const additive = !!e.originalEvent?.altKey;
|
|
||||||
selectFeature(pickPreferredFeature(features), additive);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
|
||||||
// Mở menu thao tác khi click phải lên feature.
|
|
||||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
|
||||||
if (getMode() !== "select" && getMode() !== "replay") return;
|
|
||||||
const selectableLayers = getSelectableLayers();
|
|
||||||
if (!selectableLayers.length) return;
|
|
||||||
|
|
||||||
e.preventDefault(); // block browser menu
|
|
||||||
if (getMode() === "replay") return;
|
|
||||||
if (isEditSessionActive?.()) return;
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
|
||||||
layers: selectableLayers,
|
|
||||||
}) as maplibregl.MapGeoJSONFeature[];
|
|
||||||
|
|
||||||
if (!features.length) return;
|
|
||||||
|
|
||||||
const feature = pickPreferredFeature(features);
|
|
||||||
const id = feature.id ?? feature.properties?.id;
|
|
||||||
if (id === undefined || id === null) return;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
showContextMenu(
|
|
||||||
e.originalEvent?.clientX ?? e.point.x,
|
|
||||||
e.originalEvent?.clientY ?? e.point.y,
|
|
||||||
feature,
|
|
||||||
isRightClickedItemAlreadySelected,
|
|
||||||
hasSelection
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
|
||||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
|
||||||
if (getMode() !== "select" && getMode() !== "replay") return;
|
|
||||||
const selectableLayers = getSelectableLayers();
|
|
||||||
if (!selectableLayers.length) {
|
|
||||||
map.getCanvas().style.cursor = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
|
||||||
layers: selectableLayers,
|
|
||||||
});
|
|
||||||
|
|
||||||
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectableLayers(): string[] {
|
|
||||||
const style = map.getStyle();
|
|
||||||
if (!style || !style.layers) return [];
|
|
||||||
return style.layers
|
|
||||||
.filter((layer) =>
|
|
||||||
"source" in layer &&
|
|
||||||
typeof layer.source === "string" &&
|
|
||||||
FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
|
|
||||||
)
|
|
||||||
.map((layer) => layer.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectionStateForId(id: string | number, selected: boolean) {
|
|
||||||
for (const source of FEATURE_STATE_SOURCES) {
|
|
||||||
if (!map.getSource(source)) continue;
|
|
||||||
map.setFeatureState({ source, id }, { selected });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
|
|
||||||
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
|
|
||||||
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
|
|
||||||
const geometryType = feature.geometry?.type;
|
|
||||||
const source = typeof feature.source === "string" ? feature.source : "";
|
|
||||||
|
|
||||||
if (layerId.endsWith("-hit")) return 400;
|
|
||||||
if (source === "path-arrow-shapes") return 300;
|
|
||||||
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
|
|
||||||
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
|
|
||||||
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) {
|
|
||||||
map.on("contextmenu", onRightClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
map.off("click", onClick);
|
|
||||||
map.off("mousemove", onMove);
|
|
||||||
if (hasContextActions) {
|
|
||||||
map.off("contextmenu", onRightClick);
|
|
||||||
}
|
|
||||||
clearSelection(false);
|
|
||||||
hideContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
cleanup,
|
|
||||||
clearSelection,
|
|
||||||
syncSelection,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ẩn và dọn dẹp context menu hiện tại.
|
|
||||||
function hideContextMenu() {
|
|
||||||
if (contextMenu) {
|
|
||||||
contextMenu.remove();
|
|
||||||
contextMenu = null;
|
|
||||||
}
|
|
||||||
if (docClickHandler) {
|
|
||||||
document.removeEventListener("click", docClickHandler);
|
|
||||||
docClickHandler = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
|
||||||
function showContextMenu(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
clickedFeature: maplibregl.MapGeoJSONFeature,
|
|
||||||
isRightClickedItemAlreadySelected: boolean,
|
|
||||||
hasSelection: boolean
|
|
||||||
) {
|
|
||||||
hideContextMenu();
|
|
||||||
|
|
||||||
const menu = document.createElement("div");
|
|
||||||
menu.style.position = "fixed";
|
|
||||||
menu.style.left = `${x}px`;
|
|
||||||
menu.style.top = `${y}px`;
|
|
||||||
menu.style.background = "#0f172a";
|
|
||||||
menu.style.color = "white";
|
|
||||||
menu.style.border = "1px solid #1f2937";
|
|
||||||
menu.style.borderRadius = "6px";
|
|
||||||
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
|
||||||
menu.style.zIndex = "9999";
|
|
||||||
menu.style.minWidth = "120px";
|
|
||||||
menu.style.fontSize = "14px";
|
|
||||||
menu.style.padding = "4px 0";
|
|
||||||
|
|
||||||
// Tạo một item thao tác trong context menu.
|
|
||||||
const createItem = (label: string, onClick: () => void) => {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.textContent = label;
|
|
||||||
item.style.padding = "8px 12px";
|
|
||||||
item.style.cursor = "pointer";
|
|
||||||
item.onmouseenter = () => (item.style.background = "#1f2937");
|
|
||||||
item.onmouseleave = () => (item.style.background = "transparent");
|
|
||||||
item.onclick = () => {
|
|
||||||
onClick();
|
|
||||||
hideContextMenu();
|
|
||||||
};
|
|
||||||
return item;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCount = selectedIds.size;
|
|
||||||
let hasMenuItems = false;
|
|
||||||
|
|
||||||
const effectiveCount = selectedCount || 1;
|
|
||||||
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
|
|
||||||
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
|
|
||||||
|
|
||||||
if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
|
|
||||||
const sourceIds = Array.from(selectedIds);
|
|
||||||
menu.appendChild(
|
|
||||||
createItem(
|
|
||||||
`Bind ${selectedCount} geo đang chọn vào geo này`,
|
|
||||||
() => {
|
|
||||||
onBindGeometries(targetId, sourceIds);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
hasMenuItems = true;
|
|
||||||
|
|
||||||
const separator = document.createElement("div");
|
|
||||||
separator.style.height = "1px";
|
|
||||||
separator.style.background = "#374151";
|
|
||||||
separator.style.margin = "4px 0";
|
|
||||||
menu.appendChild(separator);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isClickOutsideSelection) {
|
|
||||||
if (
|
|
||||||
effectiveCount === 1 &&
|
|
||||||
clickedFeature.source === "countries" &&
|
|
||||||
clickedFeature.geometry?.type === "Polygon" &&
|
|
||||||
onEdit
|
|
||||||
) {
|
|
||||||
const single = clickedFeature;
|
|
||||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
|
|
||||||
menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId)));
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
|
|
||||||
menu.appendChild(createItem("Hide", () => onHide(targetId)));
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onReplayEdit) {
|
|
||||||
const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId;
|
|
||||||
if (replayId !== undefined && replayId !== null) {
|
|
||||||
menu.appendChild(
|
|
||||||
createItem(
|
|
||||||
effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
|
|
||||||
() => onReplayEdit(replayId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onDelete) {
|
|
||||||
menu.appendChild(
|
|
||||||
createItem(
|
|
||||||
effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
|
|
||||||
() => {
|
|
||||||
const ids = selectedIds.size
|
|
||||||
? Array.from(selectedIds)
|
|
||||||
: [targetId];
|
|
||||||
ids.forEach((eachId) => {
|
|
||||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
|
||||||
});
|
|
||||||
clearSelection();
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasMenuItems) return;
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
contextMenu = menu;
|
|
||||||
|
|
||||||
// Đóng menu khi click ra ngoài vùng menu.
|
|
||||||
const onDocClick = (ev: MouseEvent) => {
|
|
||||||
if (!menu.contains(ev.target as Node)) {
|
|
||||||
hideContextMenu();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
docClickHandler = onDocClick;
|
|
||||||
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,6 @@ export const POINT_GEOTYPE_IDS = [
|
|||||||
|
|
||||||
export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
|
export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
|
||||||
|
|
||||||
type PointIconVariant = "default" | "draft";
|
|
||||||
|
|
||||||
type PointLayerOptions = {
|
type PointLayerOptions = {
|
||||||
iconScale?: number;
|
iconScale?: number;
|
||||||
haloRadius?: number;
|
haloRadius?: number;
|
||||||
@@ -33,12 +31,9 @@ type PointStyleConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
|
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 SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
|
||||||
|
|
||||||
const ICON_CANVAS_SIZE = 64;
|
const ICON_CANVAS_SIZE = 64;
|
||||||
const DRAFT_FILL = "#ef4444";
|
|
||||||
const DRAFT_RIM = "#7f1d1d";
|
|
||||||
const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
|
const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
|
||||||
"any",
|
"any",
|
||||||
["==", ["geometry-type"], "Point"],
|
["==", ["geometry-type"], "Point"],
|
||||||
@@ -156,7 +151,7 @@ export function buildPointGeotypeLayers(
|
|||||||
source: pointSourceId,
|
source: pointSourceId,
|
||||||
filter: pointFilter(typeId),
|
filter: pointFilter(typeId),
|
||||||
layout: {
|
layout: {
|
||||||
"icon-image": pointIconExpression(typeId),
|
"icon-image": getPointIconId(typeId),
|
||||||
"icon-size": [
|
"icon-size": [
|
||||||
"interpolate",
|
"interpolate",
|
||||||
["linear"],
|
["linear"],
|
||||||
@@ -201,14 +196,12 @@ export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean {
|
|||||||
if (typeof document === "undefined") return false;
|
if (typeof document === "undefined") return false;
|
||||||
|
|
||||||
for (const typeId of POINT_GEOTYPE_IDS) {
|
for (const typeId of POINT_GEOTYPE_IDS) {
|
||||||
for (const variant of ["default", "draft"] as const) {
|
const iconId = getPointIconId(typeId);
|
||||||
const iconId = getPointIconId(typeId, variant);
|
|
||||||
if (map.hasImage(iconId)) continue;
|
if (map.hasImage(iconId)) continue;
|
||||||
const imageData = createPointIconImageData(typeId, variant);
|
const imageData = createPointIconImageData(typeId);
|
||||||
if (!imageData) return false;
|
if (!imageData) return false;
|
||||||
map.addImage(iconId, imageData, { pixelRatio: 2 });
|
map.addImage(iconId, imageData, { pixelRatio: 2 });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -217,19 +210,13 @@ function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification
|
|||||||
return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
|
function getPointIconId(typeId: PointGeotypeId): string {
|
||||||
return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")];
|
return `point-${typeId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string {
|
function createPointIconImageData(typeId: PointGeotypeId): ImageData | null {
|
||||||
return `point-${typeId}-${variant}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null {
|
|
||||||
const config = POINT_STYLE_CONFIG[typeId];
|
const config = POINT_STYLE_CONFIG[typeId];
|
||||||
const palette = variant === "draft"
|
const palette = { fill: config.fill, rim: config.rim };
|
||||||
? { fill: DRAFT_FILL, rim: DRAFT_RIM }
|
|
||||||
: { fill: config.fill, rim: config.rim };
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = ICON_CANVAS_SIZE;
|
canvas.width = ICON_CANVAS_SIZE;
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import maplibregl, { LayerSpecification } from "maplibre-gl";
|
import maplibregl, { LayerSpecification } from "maplibre-gl";
|
||||||
|
|
||||||
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
|
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_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
|
||||||
|
|
||||||
const SELECTED_COLOR = "#22c55e";
|
const SELECTED_COLOR = "#22c55e";
|
||||||
const SELECTED_STROKE = "#14532d";
|
|
||||||
const DRAFT_COLOR = "#ef4444";
|
|
||||||
const DRAFT_STROKE = "#7f1d1d";
|
|
||||||
|
|
||||||
type ZoomStops = {
|
type ZoomStops = {
|
||||||
z1: number;
|
z1: number;
|
||||||
@@ -177,8 +169,6 @@ function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
|
|||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
SELECTED_COLOR,
|
SELECTED_COLOR,
|
||||||
DRAFT_ENTITY_EXPR,
|
|
||||||
DRAFT_COLOR,
|
|
||||||
normalColor,
|
normalColor,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -188,19 +178,12 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
|
|||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
SELECTED_COLOR,
|
SELECTED_COLOR,
|
||||||
DRAFT_ENTITY_EXPR,
|
|
||||||
DRAFT_STROKE,
|
|
||||||
normalColor,
|
normalColor,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
|
function statusFillColor(normalColor: string): string {
|
||||||
return [
|
return normalColor;
|
||||||
"case",
|
|
||||||
DRAFT_ENTITY_EXPR,
|
|
||||||
DRAFT_COLOR,
|
|
||||||
normalColor,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
|
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 {
|
export function clampYearToFixedRange(year: number): number {
|
||||||
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
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 = {
|
type EditorStoreValues = {
|
||||||
// Editor mode + draft seed.
|
// Editor mode + baseline FeatureCollection used to seed/reset useEditorState.
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
initialData: FeatureCollection;
|
baselineFeatureCollection: FeatureCollection;
|
||||||
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
|
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
@@ -54,7 +54,7 @@ type EditorStoreValues = {
|
|||||||
baselineSnapshot: EditorSnapshot | null;
|
baselineSnapshot: EditorSnapshot | null;
|
||||||
// Entity state: backend catalog plus snapshot-local rows and form/search status.
|
// Entity state: backend catalog plus snapshot-local rows and form/search status.
|
||||||
entityCatalog: Entity[];
|
entityCatalog: Entity[];
|
||||||
snapshotEntities: EntitySnapshot[];
|
snapshotEntityRows: EntitySnapshot[];
|
||||||
entityStatus: string | null;
|
entityStatus: string | null;
|
||||||
selectedFeatureIds: FeatureId[];
|
selectedFeatureIds: FeatureId[];
|
||||||
entityForm: EntityFormState;
|
entityForm: EntityFormState;
|
||||||
@@ -92,12 +92,13 @@ type EditorStoreValues = {
|
|||||||
geometryFocusRequest: GeometryFocusRequest | null;
|
geometryFocusRequest: GeometryFocusRequest | null;
|
||||||
replayFeatureId: string | number | null;
|
replayFeatureId: string | number | null;
|
||||||
hideOutside: boolean;
|
hideOutside: boolean;
|
||||||
|
// Map visibility overrides keyed by either a geometry id or a semantic geo type key.
|
||||||
geometryVisibility: Record<string, boolean>;
|
geometryVisibility: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorStoreActions = {
|
type EditorStoreActions = {
|
||||||
setMode: (next: SetStateAction<EditorMode>) => void;
|
setMode: (next: SetStateAction<EditorMode>) => void;
|
||||||
setInitialData: (next: SetStateAction<FeatureCollection>) => void;
|
setBaselineFeatureCollection: (next: SetStateAction<FeatureCollection>) => void;
|
||||||
setIsSaving: (next: SetStateAction<boolean>) => void;
|
setIsSaving: (next: SetStateAction<boolean>) => void;
|
||||||
setIsSubmitting: (next: SetStateAction<boolean>) => void;
|
setIsSubmitting: (next: SetStateAction<boolean>) => void;
|
||||||
setIsOpeningSection: (next: SetStateAction<boolean>) => void;
|
setIsOpeningSection: (next: SetStateAction<boolean>) => void;
|
||||||
@@ -111,7 +112,7 @@ type EditorStoreActions = {
|
|||||||
setProjectCommits: (next: SetStateAction<ProjectCommit[]>) => void;
|
setProjectCommits: (next: SetStateAction<ProjectCommit[]>) => void;
|
||||||
setBaselineSnapshot: (next: SetStateAction<EditorSnapshot | null>) => void;
|
setBaselineSnapshot: (next: SetStateAction<EditorSnapshot | null>) => void;
|
||||||
setEntityCatalog: (next: SetStateAction<Entity[]>) => void;
|
setEntityCatalog: (next: SetStateAction<Entity[]>) => void;
|
||||||
setSnapshotEntities: (next: SetStateAction<EntitySnapshot[]>) => void;
|
setSnapshotEntityRows: (next: SetStateAction<EntitySnapshot[]>) => void;
|
||||||
setEntityStatus: (next: SetStateAction<string | null>) => void;
|
setEntityStatus: (next: SetStateAction<string | null>) => void;
|
||||||
setSelectedFeatureIds: (next: SetStateAction<FeatureId[]>) => void;
|
setSelectedFeatureIds: (next: SetStateAction<FeatureId[]>) => void;
|
||||||
setEntityForm: (next: SetStateAction<EntityFormState>) => void;
|
setEntityForm: (next: SetStateAction<EntityFormState>) => void;
|
||||||
@@ -228,7 +229,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mode: "idle",
|
mode: "idle",
|
||||||
initialData: options.emptyFeatureCollection,
|
baselineFeatureCollection: options.emptyFeatureCollection,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
isOpeningSection: false,
|
isOpeningSection: false,
|
||||||
@@ -242,7 +243,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
|||||||
sectionCommits: [],
|
sectionCommits: [],
|
||||||
baselineSnapshot: null,
|
baselineSnapshot: null,
|
||||||
entityCatalog: [],
|
entityCatalog: [],
|
||||||
snapshotEntities: [],
|
snapshotEntityRows: [],
|
||||||
entityStatus: null,
|
entityStatus: null,
|
||||||
selectedFeatureIds: [],
|
selectedFeatureIds: [],
|
||||||
entityForm: {
|
entityForm: {
|
||||||
@@ -287,7 +288,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
|||||||
hideOutside: false,
|
hideOutside: false,
|
||||||
geometryVisibility: buildInitialGeometryVisibility(),
|
geometryVisibility: buildInitialGeometryVisibility(),
|
||||||
setMode: (next) => setValue("mode", next),
|
setMode: (next) => setValue("mode", next),
|
||||||
setInitialData: (next) => setValue("initialData", next),
|
setBaselineFeatureCollection: (next) => setValue("baselineFeatureCollection", next),
|
||||||
setIsSaving: (next) => setTaskFlag("saving", next),
|
setIsSaving: (next) => setTaskFlag("saving", next),
|
||||||
setIsSubmitting: (next) => setTaskFlag("submitting", next),
|
setIsSubmitting: (next) => setTaskFlag("submitting", next),
|
||||||
setIsOpeningSection: (next) => setTaskFlag("opening-project", next),
|
setIsOpeningSection: (next) => setTaskFlag("opening-project", next),
|
||||||
@@ -301,7 +302,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
|||||||
setProjectCommits: (next) => setValue("sectionCommits", next),
|
setProjectCommits: (next) => setValue("sectionCommits", next),
|
||||||
setBaselineSnapshot: (next) => setValue("baselineSnapshot", next),
|
setBaselineSnapshot: (next) => setValue("baselineSnapshot", next),
|
||||||
setEntityCatalog: (next) => setValue("entityCatalog", next),
|
setEntityCatalog: (next) => setValue("entityCatalog", next),
|
||||||
setSnapshotEntities: (next) => setValue("snapshotEntities", next),
|
setSnapshotEntityRows: (next) => setValue("snapshotEntityRows", next),
|
||||||
setEntityStatus: (next) => setValue("entityStatus", next),
|
setEntityStatus: (next) => setValue("entityStatus", next),
|
||||||
setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next),
|
setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next),
|
||||||
setEntityForm: (next) => setValue("entityForm", next),
|
setEntityForm: (next) => setValue("entityForm", next),
|
||||||
|
|||||||
Reference in New Issue
Block a user