refactor: enhance wiki navigation, add view mode toggle, and improve map sync preview logic

(important global check)
This commit is contained in:
taDuc
2026-05-26 03:14:14 +07:00
parent 8306543828
commit 9d04076921
9 changed files with 570 additions and 69 deletions
+212 -8
View File
@@ -22,7 +22,8 @@ import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"
import { ApiError } from "@/uhm/api/http";
import { fetchCurrentUser } from "@/uhm/api/auth";
import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import { searchGeometriesByEntityName, fetchGeometriesByBBox, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import { WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import {
Feature,
FeatureCollection,
@@ -409,6 +410,13 @@ function EditorPageContent() {
const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null);
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
const [viewMode, setViewMode] = useState<"local" | "global">("local");
const [globalGeometries, setGlobalGeometries] = useState<FeatureCollection>({
type: "FeatureCollection",
features: [],
});
const [isGlobalLoading, setIsGlobalLoading] = useState(false);
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<PreviewLinkEntityPopupState | null>(null);
const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0);
const [previewSidebarWidth, setPreviewSidebarWidth] = useState<number>(() => {
@@ -632,6 +640,119 @@ function EditorPageContent() {
? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled
: timelineFilterEnabled;
// 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.
// Fetch global geometries when viewMode is "global", timeline year changes, or timeline filter state changes
useEffect(() => {
if (viewMode !== "global") {
return;
}
let disposed = false;
setIsGlobalLoading(true);
const timeVal = activeTimelineFilterEnabled
? clampYearToFixedRange(Math.trunc(activeTimelineYear))
: undefined;
const loadGlobalData = async () => {
try {
// 1. Fetch all geometries in a single fast query
const baseFc = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timeVal,
timeRange: activeTimelineFilterEnabled ? 0 : undefined,
});
if (disposed) return;
setGlobalGeometries(baseFc);
// 2. Concurrently fetch per-entity to build the geometry-to-entity mapping
const geoToEntities: Record<string, { entity_id: string; entity_name: string; entity_ids: string[] }> = {};
const concurrency = 6;
const items = [...entities];
let nextIndex = 0;
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (true) {
if (disposed) return;
const idx = nextIndex++;
if (idx >= items.length) return;
const entity = items[idx];
try {
const fc = await fetchGeometriesByBBox({
...WORLD_BBOX,
entity_id: entity.id,
time: timeVal,
timeRange: activeTimelineFilterEnabled ? 0 : undefined,
});
if (disposed) return;
for (const feature of fc.features) {
const gid = String(feature.properties?.id);
if (!geoToEntities[gid]) {
geoToEntities[gid] = {
entity_id: entity.id,
entity_name: entity.name,
entity_ids: [entity.id],
};
} else {
if (!geoToEntities[gid].entity_ids.includes(entity.id)) {
geoToEntities[gid].entity_ids.push(entity.id);
}
}
}
} catch (e) {
console.error(`Error loading geometry mapping for entity ${entity.id}`, e);
}
}
})
);
if (disposed) return;
// 3. Update the global geometries with the enriched properties
setGlobalGeometries((prev) => {
return {
...prev,
features: prev.features.map((feature) => {
const gid = String(feature.properties?.id);
const mapping = geoToEntities[gid];
if (mapping) {
return {
...feature,
properties: {
...feature.properties,
entity_id: mapping.entity_id,
entity_name: mapping.entity_name,
entity_ids: mapping.entity_ids,
},
};
}
return feature;
}),
};
});
} catch (err) {
console.error("Load global geometries failed", err);
} finally {
if (!disposed) {
setIsGlobalLoading(false);
}
}
};
loadGlobalData();
return () => {
disposed = true;
};
}, [viewMode, activeTimelineYear, activeTimelineFilterEnabled, entities]);
// 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.
const mapRenderDraft = useMemo(() => {
@@ -643,11 +764,46 @@ function EditorPageContent() {
? editor.replayDraft
: editor.mainDraft;
if (!activeTimelineFilterEnabled) return activeDraft;
const year = clampYearToFixedRange(Math.trunc(activeTimelineYear));
const filteredDraft = activeTimelineFilterEnabled
? {
...activeDraft,
features: activeDraft.features.filter((feature) =>
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
),
}
: activeDraft;
if (viewMode === "local") {
return filteredDraft;
}
// We want to ignore any database geometries whose IDs are present in either the active local features
// or the baseline features (since those are owned by the local session/commit context).
const localFeatureIds = new Set<string>();
for (const f of filteredDraft.features) {
if (f.properties?.id != null) {
localFeatureIds.add(String(f.properties.id));
}
}
for (const f of baselineFeatureCollection.features) {
if (f.properties?.id != null) {
localFeatureIds.add(String(f.properties.id));
}
}
const mergedFeatures = [...filteredDraft.features];
// Add global features that are not owned/modified/deleted by the local session
for (const globalFeature of globalGeometries.features) {
const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null;
if (globalId === null || !localFeatureIds.has(globalId)) {
mergedFeatures.push(globalFeature);
}
}
return {
...activeDraft,
features: activeDraft.features.filter((feature) => isFeatureVisibleAtYear(feature, year)),
...filteredDraft,
features: mergedFeatures,
};
}, [
activeTimelineFilterEnabled,
@@ -658,6 +814,9 @@ function EditorPageContent() {
isViewerPreviewMode,
previewSession?.draft,
replayPreviewDraft,
viewMode,
baselineFeatureCollection.features,
globalGeometries.features,
]);
// Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại.
@@ -1913,6 +2072,16 @@ function EditorPageContent() {
setSelectedGeometryEntityIds,
]);
const handleDeleteEntity = useCallback((entityId: string) => {
const id = String(entityId || "").trim();
if (!id) return;
const confirmed = window.confirm(`Bạn có chắc chắn muốn xóa thực thể này khỏi dự án? Hành động này cũng sẽ gỡ bỏ tất cả liên kết hình học và wiki của thực thể.`);
if (!confirmed) return;
editor.deleteEntityAndRelations(id, `Xóa thực thể #${id}`);
setSelectedGeometryEntityIds((prev) => prev.filter((x) => x !== id));
flashEntityFormStatus(`Đã xóa thực thể #${id}.`, 3000);
}, [editor, flashEntityFormStatus, setSelectedGeometryEntityIds]);
// Bind/unbind geometry con vào selected geometry qua field child.bound_with.
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
if (!selectedFeatures || selectedFeatures.length === 0) {
@@ -2398,9 +2567,41 @@ function EditorPageContent() {
};
// Base draft for label lookup only. It must not decide which geometry is rendered.
const labelContextBaseDraft = isAnyPreviewMode
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
: editor.draft;
const labelContextBaseDraft = useMemo(() => {
const baseDraft = isAnyPreviewMode
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
: editor.draft;
if (viewMode === "local") {
return baseDraft;
}
const localFeatureIds = new Set<string>();
for (const f of baseDraft.features) {
if (f.properties?.id != null) {
localFeatureIds.add(String(f.properties.id));
}
}
for (const f of baselineFeatureCollection.features) {
if (f.properties?.id != null) {
localFeatureIds.add(String(f.properties.id));
}
}
const mergedFeatures = [...baseDraft.features];
for (const globalFeature of globalGeometries.features) {
const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null;
if (globalId === null || !localFeatureIds.has(globalId)) {
mergedFeatures.push(globalFeature);
}
}
return {
...baseDraft,
features: mergedFeatures,
};
}, [viewMode, isAnyPreviewMode, previewSession?.draft, editor.draft, baselineFeatureCollection.features, globalGeometries.features]);
// Enriched label context may contain geometries that mapRenderDraft filtered out.
// Map rendering must still use mapRenderDraft above.
const mapLabelContextDraft = useMemo(() => {
@@ -2657,6 +2858,8 @@ function EditorPageContent() {
onEnterPreview={!isReplayEditMode && !isAnyPreviewMode ? openViewerPreview : undefined}
onExitPreview={isReplayPreviewMode ? exitReplayPreview : isViewerPreviewMode ? exitViewerPreview : undefined}
onPlayPreviewReplay={isViewerPreviewMode && viewerPreviewSelectedReplay ? openSelectedViewerReplayPreview : undefined}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
@@ -3078,6 +3281,7 @@ function EditorPageContent() {
selectedGeometryTime={selectedGeometryTime}
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
onRerollEntityId={handleRerollEntityId}
onDeleteEntity={handleDeleteEntity}
/>
<WikiSidebarPanel