feat: implement Goong place search integration and add geometry representative point utilities

This commit is contained in:
taDuc
2026-05-26 15:43:20 +07:00
parent faf5c56219
commit 8c4a9cc85f
10 changed files with 1316 additions and 8 deletions
+105 -2
View File
@@ -18,6 +18,7 @@ import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPan
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel"; import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel";
import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch";
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
import { ApiError } from "@/uhm/api/http"; import { ApiError } from "@/uhm/api/http";
import { fetchCurrentUser } from "@/uhm/api/auth"; import { fetchCurrentUser } from "@/uhm/api/auth";
@@ -84,6 +85,7 @@ import {
normalizeReplaysForCompare, normalizeReplaysForCompare,
normalizeWikisForCompare, normalizeWikisForCompare,
} from "@/uhm/lib/editor/editorPageUtils"; } from "@/uhm/lib/editor/editorPageUtils";
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
const CURRENT_YEAR = new Date().getUTCFullYear(); const CURRENT_YEAR = new Date().getUTCFullYear();
const DEFAULT_EDITOR_USER_ID = "local-editor"; const DEFAULT_EDITOR_USER_ID = "local-editor";
@@ -411,6 +413,7 @@ function EditorPageContent() {
const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null); const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null);
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null); const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false); const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
const [viewMode, setViewMode] = useState<"local" | "global">("local"); const [viewMode, setViewMode] = useState<"local" | "global">("local");
const [globalGeometries, setGlobalGeometries] = useState<FeatureCollection>({ const [globalGeometries, setGlobalGeometries] = useState<FeatureCollection>({
@@ -466,6 +469,23 @@ function EditorPageContent() {
const isViewerPreviewMode = mode === "preview"; const isViewerPreviewMode = mode === "preview";
const isReplayPreviewMode = mode === "replay_preview"; const isReplayPreviewMode = mode === "replay_preview";
const isAnyPreviewMode = isViewerPreviewMode || isReplayPreviewMode; const isAnyPreviewMode = isViewerPreviewMode || isReplayPreviewMode;
const clearPresentPlaceFocus = useCallback(() => {
setFocusedPresentPlace(null);
}, []);
const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => {
const map = getCurrentMapInstance();
if (!map) return;
map.flyTo({
center: [place.lng, place.lat],
zoom: Math.max(map.getZoom(), 13.5),
duration: 900,
essential: true,
});
setFocusedPresentPlace(place);
setPreviewFeaturePopupAnchor(null);
setPreviewLinkEntityPopup(null);
}, [getCurrentMapInstance]);
const previewReturnModeRef = useRef<EditorMode>("select"); const previewReturnModeRef = useRef<EditorMode>("select");
const replayPreviewReturnRef = useRef<{ const replayPreviewReturnRef = useRef<{
mode: "replay" | "preview"; mode: "replay" | "preview";
@@ -486,6 +506,12 @@ function EditorPageContent() {
}; };
}, []); }, []);
useEffect(() => {
if (!isAnyPreviewMode) {
clearPresentPlaceFocus();
}
}, [clearPresentPlaceFocus, isAnyPreviewMode]);
useEffect(() => { useEffect(() => {
if (!imageOverlayKeyboardEnabled) return; if (!imageOverlayKeyboardEnabled) return;
@@ -1430,6 +1456,71 @@ function EditorPageContent() {
: EMPTY_FEATURE_COLLECTION; : EMPTY_FEATURE_COLLECTION;
const isReplayPreviewWikiSidebarOpen = isAnyPreviewMode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen); const isReplayPreviewWikiSidebarOpen = isAnyPreviewMode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
const map = getCurrentMapInstance();
const geometryId = String(payload.geometry.id || "").trim();
if (!geometryId) return;
const feature: Feature = {
type: "Feature",
properties: {
id: geometryId,
source: "ref",
type: payload.geometry.type,
time_start: normalizeTimelineYearValue(payload.geometry.time_start),
time_end: normalizeTimelineYearValue(payload.geometry.time_end),
bound_with: normalizeGeoSearchBoundWith(payload.geometry.bound_with),
entity_id: payload.entity.entity_id,
entity_ids: [payload.entity.entity_id],
entity_name: payload.entity.name,
entity_names: [payload.entity.name],
},
geometry: payload.geometry.draw_geometry,
};
if (activeTimelineFilterEnabled && payload.geometry.time_start != null) {
const nextYear = clampYearToFixedRange(Math.trunc(payload.geometry.time_start));
if (isReplayPreviewMode) {
replayPreview.setTimelineYear(nextYear);
} else if (isViewerPreviewMode) {
handleViewerPreviewTimelineYearChange(nextYear);
}
}
if (map) {
fitMapToFeatureCollection(
map,
{ type: "FeatureCollection", features: [feature] },
isReplayPreviewWikiSidebarOpen
? {
top: 96,
right: previewSidebarWidth + 96,
bottom: 120,
left: 96,
}
: 96,
{ duration: 900, maxZoom: 10, pointZoom: 13 }
);
}
const renderedFeature = mapRenderDraft.features.find((item) => String(item.properties.id) === geometryId) || null;
setSelectedFeatureIds(renderedFeature ? [renderedFeature.properties.id] : []);
setFocusedPresentPlace(null);
setPreviewFeaturePopupAnchor(null);
setPreviewLinkEntityPopup(null);
}, [
activeTimelineFilterEnabled,
getCurrentMapInstance,
handleViewerPreviewTimelineYearChange,
isReplayPreviewMode,
isReplayPreviewWikiSidebarOpen,
isViewerPreviewMode,
mapRenderDraft.features,
previewSidebarWidth,
replayPreview,
setSelectedFeatureIds,
]);
const closeReplayPreviewSidebar = useCallback(() => { const closeReplayPreviewSidebar = useCallback(() => {
closeReplayPreviewWikiPanel(); closeReplayPreviewWikiPanel();
setPreviewActiveEntityId(null); setPreviewActiveEntityId(null);
@@ -1885,13 +1976,16 @@ function EditorPageContent() {
useEffect(() => { useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const renderedFeatureIds = new Set(
mapRenderDraft.features.map((feature) => String(feature.properties.id))
);
const stillExistIds = selectedFeatureIds.filter(id => const stillExistIds = selectedFeatureIds.filter(id =>
editor.draft.features.some(feature => String(feature.properties.id) === String(id)) renderedFeatureIds.has(String(id))
); );
if (stillExistIds.length !== selectedFeatureIds.length) { if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds); setSelectedFeatureIds(stillExistIds);
} }
}, [editor.draft.features, selectedFeatureIds, setSelectedFeatureIds]); }, [mapRenderDraft.features, selectedFeatureIds, setSelectedFeatureIds]);
useEffect(() => { useEffect(() => {
if (!selectedFeature) { if (!selectedFeature) {
@@ -2955,6 +3049,15 @@ function EditorPageContent() {
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)} )}
{isAnyPreviewMode ? (
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus}
rightOffset={isReplayPreviewWikiSidebarOpen ? previewSidebarWidth + 48 : 18}
/>
) : null}
{isReplayPreviewMode ? ( {isReplayPreviewMode ? (
<ReplayPreviewOverlay <ReplayPreviewOverlay
isPreviewMode={true} isPreviewMode={true}
+4 -1
View File
@@ -33,7 +33,10 @@ export function buildGoongProxyUrl(rawUrl: string): string {
.replace(/^https?:\/\//i, "") .replace(/^https?:\/\//i, "")
.replace(/^\/+/, ""); .replace(/^\/+/, "");
return `${GOONG_PROXY_BASE_PATH}/${proxyTarget}`; const isMapProxy = !proxyTarget.startsWith("rsapi.goong.io");
const proxyPrefix = isMapProxy ? `${API_BASE_URL}/map/proxy` : `${API_BASE_URL}/api/proxy`;
return `${proxyPrefix}/${proxyTarget}`;
} }
export const GOONG_GLYPHS_PROXY_URL = buildGoongProxyUrl(GOONG_GLYPHS_UPSTREAM_URL); export const GOONG_GLYPHS_PROXY_URL = buildGoongProxyUrl(GOONG_GLYPHS_UPSTREAM_URL);
+188
View File
@@ -0,0 +1,188 @@
import { buildGoongProxyUrl } from "@/uhm/api/config";
const GOONG_PLACE_API_URL = "https://rsapi.goong.io/Place";
const GOONG_GEOCODE_API_URL = "https://rsapi.goong.io/Geocode";
export type PresentPlacePrediction = {
placeId: string;
description: string;
mainText: string;
secondaryText: string;
};
export type PresentPlaceSelection = {
placeId: string;
name: string;
address: string;
lat: number;
lng: number;
};
export type ReverseGeocodePlace = {
label: string;
address: string;
lat: number;
lng: number;
};
export function hasSearchMapApiKey(): boolean {
return true;
}
export async function searchPresentPlaces(
input: string,
signal?: AbortSignal
): Promise<PresentPlacePrediction[]> {
const keyword = input.trim();
if (keyword.length < 2) return [];
const proxyBase = buildGoongProxyUrl(`${GOONG_PLACE_API_URL}/AutoComplete`);
const url = `${proxyBase}?input=${encodeURIComponent(keyword)}&limit=8`;
const payload = await fetchGoongJson(url, signal);
const predictions = Array.isArray(payload.predictions) ? payload.predictions : [];
return predictions
.map(normalizePrediction)
.filter((prediction): prediction is PresentPlacePrediction => Boolean(prediction));
}
export async function fetchPresentPlaceDetail(
placeId: string,
signal?: AbortSignal
): Promise<PresentPlaceSelection> {
const id = placeId.trim();
if (!id) {
throw new Error("Thiếu place_id.");
}
const proxyBase = buildGoongProxyUrl(`${GOONG_PLACE_API_URL}/Detail`);
const url = `${proxyBase}?place_id=${encodeURIComponent(id)}`;
const payload = await fetchGoongJson(url, signal);
const result = isRecord(payload.result) ? payload.result : null;
const location = isRecord(result?.geometry) && isRecord(result.geometry.location)
? result.geometry.location
: null;
const lat = toFiniteNumber(location?.lat);
const lng = toFiniteNumber(location?.lng);
if (lat === null || lng === null) {
throw new Error("Không tìm thấy tọa độ địa điểm.");
}
const name = normalizeText(result?.name) || normalizeText(result?.formatted_address) || id;
const address = normalizeText(result?.formatted_address) || normalizeText(result?.address) || name;
return {
placeId: id,
name,
address,
lat,
lng,
};
}
export async function reverseGeocodePresentPlace(
lng: number,
lat: number,
signal?: AbortSignal
): Promise<ReverseGeocodePlace> {
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
throw new Error("Tọa độ reverse geocode không hợp lệ.");
}
const proxyBase = buildGoongProxyUrl(GOONG_GEOCODE_API_URL);
const url = `${proxyBase}?latlng=${lat},${lng}`;
const payload = await fetchGoongJson(url, signal);
const results = Array.isArray(payload.results) ? payload.results : [];
const firstResult = results.find((item) => isRecord(item)) as Record<string, unknown> | undefined;
if (!firstResult) {
throw new Error("Không tìm thấy địa chỉ gần tọa độ này.");
}
const address = normalizeText(firstResult.formatted_address) ||
normalizeText(firstResult.description) ||
normalizeText(firstResult.name);
const label = buildReverseGeocodeLabel(firstResult) || address;
if (!label && !address) {
throw new Error("Goong không trả về địa chỉ hợp lệ.");
}
return {
label: label || address,
address: address || label,
lat,
lng,
};
}
async function fetchGoongJson(url: string | URL, signal?: AbortSignal): Promise<Record<string, unknown>> {
const response = await fetch(url.toString(), { signal });
if (!response.ok) {
throw new Error(`Goong request failed (${response.status}).`);
}
const payload = await response.json() as unknown;
if (!isRecord(payload)) {
throw new Error("Goong response không hợp lệ.");
}
const status = normalizeText(payload.status).toUpperCase();
if (status && status !== "OK") {
const message = normalizeText(payload.error_message) || normalizeText(payload.message) || "Goong request failed.";
throw new Error(message);
}
return payload;
}
function normalizePrediction(input: unknown): PresentPlacePrediction | null {
if (!isRecord(input)) return null;
const placeId = normalizeText(input.place_id);
const description = normalizeText(input.description);
if (!placeId || !description) return null;
const structured = isRecord(input.structured_formatting) ? input.structured_formatting : null;
const mainText = normalizeText(structured?.main_text) || description;
const secondaryText = normalizeText(structured?.secondary_text);
return {
placeId,
description,
mainText,
secondaryText,
};
}
function buildReverseGeocodeLabel(result: Record<string, unknown>): string {
const compound = isRecord(result.compound) ? result.compound : null;
if (compound) {
const parts = [
normalizeText(compound.commune),
normalizeText(compound.district),
normalizeText(compound.province),
].filter((part) => part.length > 0);
if (parts.length) {
return Array.from(new Set(parts)).join(", ");
}
}
return normalizeText(result.formatted_address) ||
normalizeText(result.description) ||
normalizeText(result.name);
}
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function toFiniteNumber(value: unknown): number | null {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,645 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import {
fetchPresentPlaceDetail,
hasSearchMapApiKey,
reverseGeocodePresentPlace,
searchPresentPlaces,
type PresentPlacePrediction,
type PresentPlaceSelection,
} from "@/uhm/api/goongPlaces";
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
export type { PresentPlaceSelection } from "@/uhm/api/goongPlaces";
export type HistoricalGeometryFocusPayload = {
entity: EntityGeometriesSearchItem;
geometry: EntityGeometrySearchGeo;
representativePoint: [number, number] | null;
adminLabel: string | null;
};
type SearchMode = "present" | "history";
type AdminLabelState = {
status: "loading" | "loaded" | "error";
label: string | null;
address: string | null;
};
type Props = {
focusedPlace: PresentPlaceSelection | null;
onFocusPlace: (place: PresentPlaceSelection) => void;
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
onClearFocus: () => void;
rightOffset?: number;
};
export default function PresentPlaceSearch({
focusedPlace,
onFocusPlace,
onFocusHistoricalGeometry,
onClearFocus,
rightOffset = 18,
}: Props) {
const [mode, setMode] = useState<SearchMode>("present");
const [query, setQuery] = useState("");
const [results, setResults] = useState<PresentPlacePrediction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectingPlaceId, setSelectingPlaceId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [historicalQuery, setHistoricalQuery] = useState("");
const [historicalResults, setHistoricalResults] = useState<EntityGeometriesSearchItem[]>([]);
const [isHistoricalLoading, setIsHistoricalLoading] = useState(false);
const [historicalError, setHistoricalError] = useState<string | null>(null);
const [expandedEntityId, setExpandedEntityId] = useState<string | null>(null);
const [selectingGeometryId, setSelectingGeometryId] = useState<string | null>(null);
const [adminLabels, setAdminLabels] = useState<Record<string, AdminLabelState>>({});
const [isOpen, setIsOpen] = useState(false);
const requestSeqRef = useRef(0);
const historicalRequestSeqRef = useRef(0);
const hasApiKey = hasSearchMapApiKey();
const activeQuery = mode === "present" ? query : historicalQuery;
const activeError = mode === "present" ? error : historicalError;
const activeLoading = mode === "present" ? isLoading : isHistoricalLoading;
const expandedItem = useMemo(() => {
if (!expandedEntityId) return null;
return historicalResults.find((item) => item.entity_id === expandedEntityId) || null;
}, [expandedEntityId, historicalResults]);
useEffect(() => {
if (mode !== "present") return;
const keyword = query.trim();
if (!keyword || keyword.length < 2) {
setResults([]);
setIsLoading(false);
setError(null);
return;
}
if (!hasApiKey) {
setResults([]);
setIsLoading(false);
setError("Thiếu SEARCH_MAP_API_KEY.");
return;
}
const controller = new AbortController();
const seq = requestSeqRef.current + 1;
requestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsLoading(true);
setError(null);
searchPresentPlaces(keyword, controller.signal)
.then((nextResults) => {
if (requestSeqRef.current !== seq) return;
setResults(nextResults);
setIsOpen(true);
})
.catch((err) => {
if (controller.signal.aborted || requestSeqRef.current !== seq) return;
setResults([]);
setError(err instanceof Error ? err.message : "Không search được địa điểm.");
})
.finally(() => {
if (requestSeqRef.current === seq) {
setIsLoading(false);
}
});
}, 260);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [hasApiKey, mode, query]);
useEffect(() => {
if (mode !== "history") return;
const keyword = historicalQuery.trim();
if (!keyword || keyword.length < 2) {
setHistoricalResults([]);
setIsHistoricalLoading(false);
setHistoricalError(null);
setExpandedEntityId(null);
return;
}
const seq = historicalRequestSeqRef.current + 1;
historicalRequestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsHistoricalLoading(true);
setHistoricalError(null);
searchGeometriesByEntityName(keyword, { limit: 12 })
.then((response) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults(response.items || []);
setExpandedEntityId(null);
setIsOpen(true);
})
.catch((err) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults([]);
setHistoricalError(err instanceof Error ? err.message : "Không search được entity lịch sử.");
})
.finally(() => {
if (historicalRequestSeqRef.current === seq) {
setIsHistoricalLoading(false);
}
});
}, 260);
return () => window.clearTimeout(timer);
}, [historicalQuery, mode]);
useEffect(() => {
if (mode !== "history" || !expandedItem || expandedItem.geometries.length <= 1 || !hasApiKey) {
return;
}
const controller = new AbortController();
for (const geometry of expandedItem.geometries) {
const point = getGeometryRepresentativePoint(geometry.draw_geometry);
if (!point) {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
continue;
}
setAdminLabels((prev) => {
if (prev[geometry.id]) return prev;
return {
...prev,
[geometry.id]: { status: "loading", label: null, address: null },
};
});
reverseGeocodePresentPlace(point[0], point[1], controller.signal)
.then((place) => {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: {
status: "loaded",
label: place.label,
address: place.address,
},
}));
})
.catch((err) => {
if (controller.signal.aborted) return;
console.warn("Reverse geocode historical geometry failed", err);
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
});
}
return () => controller.abort();
}, [expandedItem, hasApiKey, mode]);
const selectPrediction = async (prediction: PresentPlacePrediction) => {
setSelectingPlaceId(prediction.placeId);
setError(null);
try {
const place = await fetchPresentPlaceDetail(prediction.placeId);
onFocusPlace(place);
setQuery(place.name || prediction.description);
setResults([]);
setIsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Không lấy được tọa độ địa điểm.");
} finally {
setSelectingPlaceId(null);
}
};
const selectHistoricalEntity = (item: EntityGeometriesSearchItem) => {
if (item.geometries.length === 1) {
selectHistoricalGeometry(item, item.geometries[0]);
return;
}
if (item.geometries.length > 1) {
setExpandedEntityId((prev) => prev === item.entity_id ? null : item.entity_id);
}
};
const selectHistoricalGeometry = (
item: EntityGeometriesSearchItem,
geometry: EntityGeometrySearchGeo
) => {
setSelectingGeometryId(geometry.id);
const labelState = adminLabels[geometry.id] || null;
onFocusHistoricalGeometry({
entity: item,
geometry,
representativePoint: getGeometryRepresentativePoint(geometry.draw_geometry),
adminLabel: labelState?.label || null,
});
setHistoricalQuery(item.name);
setHistoricalResults([]);
setExpandedEntityId(null);
setIsOpen(false);
setSelectingGeometryId(null);
};
const clearSearch = () => {
if (mode === "present") {
setQuery("");
setResults([]);
setError(null);
} else {
setHistoricalQuery("");
setHistoricalResults([]);
setHistoricalError(null);
setExpandedEntityId(null);
}
setIsOpen(false);
onClearFocus();
};
const switchMode = (nextMode: SearchMode) => {
setMode(nextMode);
setIsOpen(true);
setError(null);
setHistoricalError(null);
};
return (
<div
style={{
position: "absolute",
top: 10,
right: rightOffset,
zIndex: 18,
width: "min(392px, calc(100vw - 36px))",
pointerEvents: "auto",
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div style={searchCardStyle}>
<div style={searchInputRowStyle}>
<button
type="button"
onClick={() => switchMode(mode === "present" ? "history" : "present")}
title={mode === "present" ? "Switch to history search" : "Switch to present search"}
aria-label={mode === "present" ? "Switch to history search" : "Switch to present search"}
style={modeSwitchStyle}
>
{mode === "present" ? "Present" : "History"}
</button>
<input
value={activeQuery}
onChange={(event) => {
if (mode === "present") {
setQuery(event.target.value);
} else {
setHistoricalQuery(event.target.value);
}
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
return;
}
if (event.key === "Enter") {
if (mode === "present" && results[0]) {
event.preventDefault();
void selectPrediction(results[0]);
}
if (mode === "history" && historicalResults[0]) {
event.preventDefault();
selectHistoricalEntity(historicalResults[0]);
}
}
}}
disabled={mode === "present" && !hasApiKey}
placeholder={mode === "present" ? "Tìm địa điểm hiện tại" : "Tìm entity lịch sử"}
style={inputStyle}
/>
{activeQuery || focusedPlace ? (
<button
type="button"
onClick={clearSearch}
title="Clear"
aria-label="Clear place search"
style={clearButtonStyle}
>
x
</button>
) : null}
</div>
</div>
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults) ? (
<div style={resultsPanelStyle}>
{mode === "present" ? (
<PresentResults
isLoading={isLoading}
error={error}
query={query}
results={results}
selectingPlaceId={selectingPlaceId}
onSelect={selectPrediction}
/>
) : (
<HistoricalResults
isLoading={isHistoricalLoading}
error={historicalError}
query={historicalQuery}
results={historicalResults}
expandedEntityId={expandedEntityId}
adminLabels={adminLabels}
selectingGeometryId={selectingGeometryId}
hasApiKey={hasApiKey}
onSelectEntity={selectHistoricalEntity}
onSelectGeometry={selectHistoricalGeometry}
/>
)}
</div>
) : null}
</div>
);
}
function PresentResults({
isLoading,
error,
query,
results,
selectingPlaceId,
onSelect,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: PresentPlacePrediction[];
selectingPlaceId: string | null;
onSelect: (prediction: PresentPlacePrediction) => Promise<void>;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không kết quả.</div>;
return (
<>
{results.map((result) => (
<button
key={result.placeId}
type="button"
onClick={() => void onSelect(result)}
disabled={selectingPlaceId === result.placeId}
style={{
...resultButtonStyle,
cursor: selectingPlaceId === result.placeId ? "wait" : "pointer",
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{result.mainText}</span>
<span style={secondaryResultTextStyle}>{result.secondaryText || result.description}</span>
</button>
))}
</>
);
}
function HistoricalResults({
isLoading,
error,
query,
results,
expandedEntityId,
adminLabels,
selectingGeometryId,
hasApiKey,
onSelectEntity,
onSelectGeometry,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: EntityGeometriesSearchItem[];
expandedEntityId: string | null;
adminLabels: Record<string, AdminLabelState>;
selectingGeometryId: string | null;
hasApiKey: boolean;
onSelectEntity: (item: EntityGeometriesSearchItem) => void;
onSelectGeometry: (item: EntityGeometriesSearchItem, geometry: EntityGeometrySearchGeo) => void;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm entity...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không entity phù hợp.</div>;
return (
<>
{results.map((item) => {
const isExpanded = expandedEntityId === item.entity_id;
return (
<div key={item.entity_id} style={{ borderBottom: "1px solid rgba(148, 163, 184, 0.12)" }}>
<button
type="button"
onClick={() => onSelectEntity(item)}
disabled={!item.geometries.length}
style={{
...resultButtonStyle,
cursor: item.geometries.length ? "pointer" : "not-allowed",
}}
onMouseEnter={(event) => {
if (item.geometries.length) event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{item.name || item.entity_id}</span>
<span style={secondaryResultTextStyle}>
{item.geometries.length
? `${item.geometries.length} geometry${item.geometries.length > 1 ? "s" : ""}`
: "Không có geometry"}
{item.description ? ` · ${item.description}` : ""}
</span>
</button>
{isExpanded ? (
<div style={{ padding: "0 8px 8px", display: "grid", gap: 6 }}>
{!hasApiKey ? (
<div style={{ ...statusStyle, padding: "7px 8px" }}>
Thiếu SEARCH_MAP_API_KEY đ lấy đa danh hiện tại.
</div>
) : null}
{item.geometries.map((geometry) => (
<button
key={geometry.id}
type="button"
onClick={() => onSelectGeometry(item, geometry)}
disabled={selectingGeometryId === geometry.id}
style={{
border: "1px solid rgba(148, 163, 184, 0.16)",
borderRadius: 8,
background: "rgba(15, 23, 42, 0.68)",
color: "#e2e8f0",
padding: "8px 9px",
textAlign: "left",
cursor: selectingGeometryId === geometry.id ? "wait" : "pointer",
}}
>
<span style={{ ...primaryResultTextStyle, fontSize: 12 }}>
{formatAdminLabel(adminLabels[geometry.id])}
</span>
<span style={{ ...secondaryResultTextStyle, marginTop: 3 }}>
{formatGeometryMeta(geometry)}
</span>
</button>
))}
</div>
) : null}
</div>
);
})}
</>
);
}
function shouldRenderResults(
mode: SearchMode,
query: string,
isLoading: boolean,
error: string | null,
presentResults: PresentPlacePrediction[],
historicalResults: EntityGeometriesSearchItem[]
): boolean {
if (isLoading || error || query.trim().length >= 2) return true;
return mode === "present" ? presentResults.length > 0 : historicalResults.length > 0;
}
function formatAdminLabel(state: AdminLabelState | undefined): string {
if (!state || state.status === "loading") return "Đang lấy địa danh hiện tại...";
if (state.status === "error") return "Không lấy được địa danh hiện tại";
return state.label || state.address || "Địa danh hiện tại không rõ";
}
function formatGeometryMeta(geometry: EntityGeometrySearchGeo): string {
const type = geometry.type || "geometry";
const timeStart = geometry.time_start ?? null;
const timeEnd = geometry.time_end ?? null;
const time =
timeStart !== null && timeEnd !== null
? `${timeStart} - ${timeEnd}`
: timeStart !== null
? `từ ${timeStart}`
: timeEnd !== null
? `đến ${timeEnd}`
: "không rõ thời gian";
return `${type} · ${time}`;
}
const searchCardStyle = {
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.92)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.35)",
color: "#e2e8f0",
overflow: "hidden",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const searchInputRowStyle = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 10px",
} satisfies CSSProperties;
const inputStyle = {
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
color: "#f8fafc",
fontSize: 13,
fontWeight: 700,
} satisfies CSSProperties;
const modeSwitchStyle = {
border: "none",
borderRight: "1px solid rgba(148, 163, 184, 0.22)",
background: "transparent",
color: "#38bdf8",
padding: "0 10px 0 0",
fontSize: 11,
fontWeight: 900,
cursor: "pointer",
lineHeight: 1,
} satisfies CSSProperties;
const clearButtonStyle = {
width: 26,
height: 26,
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 6,
background: "rgba(15, 23, 42, 0.74)",
color: "#cbd5e1",
cursor: "pointer",
fontWeight: 900,
} satisfies CSSProperties;
const resultsPanelStyle = {
marginTop: 8,
overflow: "hidden",
border: "1px solid rgba(148, 163, 184, 0.24)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.96)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.4)",
color: "#e2e8f0",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const resultButtonStyle = {
display: "grid",
gap: 3,
width: "100%",
padding: "10px 12px",
border: "none",
background: "transparent",
color: "#e2e8f0",
textAlign: "left",
} satisfies CSSProperties;
const primaryResultTextStyle = {
display: "block",
fontSize: 13,
fontWeight: 900,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const secondaryResultTextStyle = {
display: "block",
fontSize: 11,
color: "#94a3b8",
lineHeight: 1.35,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const statusStyle = {
padding: "11px 12px",
color: "#94a3b8",
fontSize: 12,
fontWeight: 700,
} satisfies CSSProperties;
+103
View File
@@ -419,6 +419,39 @@ export function collectCoordinatePairs(value: unknown): Array<[number, number]>
return value.flatMap((item) => collectCoordinatePairs(item)); return value.flatMap((item) => collectCoordinatePairs(item));
} }
export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate | null {
if (geometry.type === "Point") {
return normalizeCoordinate(geometry.coordinates);
}
if (geometry.type === "MultiPoint") {
return getAverageCoordinate(geometry.coordinates);
}
if (geometry.type === "LineString") {
return getLineMidpointCoordinate(geometry.coordinates);
}
if (geometry.type === "MultiLineString") {
let bestLine: Coordinate[] | null = null;
let bestLength = -1;
for (const line of geometry.coordinates) {
const length = getLineLength(line);
if (length > bestLength) {
bestLength = length;
bestLine = line;
}
}
return bestLine ? getLineMidpointCoordinate(bestLine) : null;
}
if (geometry.type === "Polygon" || geometry.type === "MultiPolygon") {
return getPolygonLabelPoint(geometry);
}
return null;
}
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features: Feature[] = []; const features: Feature[] = [];
@@ -877,6 +910,76 @@ function isLineGeometry(geometry: Geometry): boolean {
return geometry.type === "LineString" || geometry.type === "MultiLineString"; return geometry.type === "LineString" || geometry.type === "MultiLineString";
} }
function normalizeCoordinate(value: unknown): Coordinate | null {
if (!Array.isArray(value) || value.length < 2) return null;
const lng = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
return [lng, lat];
}
function getAverageCoordinate(coordinates: Coordinate[]): Coordinate | null {
const valid = coordinates
.map((coordinate) => normalizeCoordinate(coordinate))
.filter((coordinate): coordinate is Coordinate => Boolean(coordinate));
if (!valid.length) return null;
const sum = valid.reduce(
(acc, coordinate) => ({
lng: acc.lng + coordinate[0],
lat: acc.lat + coordinate[1],
}),
{ lng: 0, lat: 0 }
);
return [sum.lng / valid.length, sum.lat / valid.length];
}
function getLineMidpointCoordinate(coordinates: Coordinate[]): Coordinate | null {
const valid = coordinates
.map((coordinate) => normalizeCoordinate(coordinate))
.filter((coordinate): coordinate is Coordinate => Boolean(coordinate));
if (!valid.length) return null;
if (valid.length === 1) return valid[0];
const totalLength = getLineLength(valid);
if (totalLength <= 0) return valid[Math.floor(valid.length / 2)];
const halfway = totalLength / 2;
let travelled = 0;
for (let i = 1; i < valid.length; i += 1) {
const prev = valid[i - 1];
const next = valid[i];
const segmentLength = getCoordinateDistance(prev, next);
if (travelled + segmentLength >= halfway) {
const ratio = segmentLength > 0 ? (halfway - travelled) / segmentLength : 0;
return [
prev[0] + (next[0] - prev[0]) * ratio,
prev[1] + (next[1] - prev[1]) * ratio,
];
}
travelled += segmentLength;
}
return valid[valid.length - 1];
}
function getLineLength(coordinates: Coordinate[]): number {
let total = 0;
for (let i = 1; i < coordinates.length; i += 1) {
const prev = normalizeCoordinate(coordinates[i - 1]);
const next = normalizeCoordinate(coordinates[i]);
if (!prev || !next) continue;
total += getCoordinateDistance(prev, next);
}
return total;
}
function getCoordinateDistance(a: Coordinate, b: Coordinate): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
return Math.sqrt(dx * dx + dy * dy);
}
function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] { function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
if (geometry.type === "LineString") return [geometry.coordinates]; if (geometry.type === "LineString") return [geometry.coordinates];
if (geometry.type === "MultiLineString") return geometry.coordinates; if (geometry.type === "MultiLineString") return geometry.coordinates;
+69
View File
@@ -0,0 +1,69 @@
# UHM Editor - Luồng Dữ Liệu Commit và Load Snapshot (FrontEnd)
Tài liệu này giải thích chi tiết vòng đời của dữ liệu liên kết (binding) giữa **Geometry****Entity** trên FrontEnd (FE), từ lúc người dùng thao tác trên giao diện, đóng gói gửi lên API (Commit), cho đến khi tải ngược lại từ API về Editor (Load).
---
## 1. Sơ đồ tổng quan luồng dữ liệu
```mermaid
graph TD
A[UI: Click Bind/Unbind] -->|Cập nhật| B(Editor Memory State: properties.entity_ids)
B -->|Bấm Commit| C(buildEditorSnapshot)
C -->|1. Xóa entity_ids khỏi GeoJSON<br>2. So sánh Baseline để tính operation: binding/delete/reference| D(Snapshot JSON phẳng)
D -->|POST /projects/:id/commits| E[Backend: Lưu thô JSON]
E -->|Mở Editor / Pull Commit| F(toEditorSessionSnapshot & normalizeEditorSnapshot)
F -->|1. Lọc bỏ các dòng operation = delete<br>2. Ghép ngược entity_id vào GeoJSON properties| G(Editor Memory State mới)
```
---
## 2. Các giai đoạn chi tiết
### Giai đoạn 1: Editor Runtime State (Đang chỉnh sửa)
Khi người dùng đang mở Editor, dữ liệu liên kết được lưu trực tiếp trong thuộc tính của đối tượng hình học (GeoJSON Feature Properties) để hiển thị nhanh trên bản đồ:
- `feature.properties.entity_ids`: Mảng chứa các ID của thực thể đang liên kết (Ví dụ: `["entity-uuid-1", "entity-uuid-2"]`).
- `feature.properties.entity_id`: ID của thực thể chính (thường là phần tử đầu tiên).
### Giai đoạn 2: Trước khi gửi Commit (Build Snapshot)
Để đảm bảo cơ sở dữ liệu snapshot gọn nhẹ và không bị dư thừa dữ liệu (normalized database), hàm `buildEditorSnapshot()` sẽ thực hiện hai việc quan trọng:
1. **Xóa thuộc tính liên kết trong GeoJSON:**
Trước khi lưu `editor_feature_collection`, FE sẽ **xóa sạch** các trường động như `entity_id`, `entity_ids`, `entity_name`, `entity_names` khỏi thuộc tính của Feature. Do đó, phần GeoJSON lưu trong snapshot **không chứa** thông tin liên kết thực thể.
2. **Chuyển đổi thành bảng liên kết phẳng (`geometry_entity[]`):**
FE so sánh danh sách liên kết hiện tại trong draft với **Baseline** (dữ liệu của commit trước đó) để tính toán cờ hành động (`operation`) cho từng cặp liên kết:
- **`binding` (Tạo mới):** Có trong draft hiện tại nhưng **không có** trong baseline.
- **`reference` (Không đổi):** Có trong cả draft hiện tại và baseline.
- **`delete` (Xóa bỏ):** **Có** trong baseline cũ nhưng **không còn** trong draft hiện tại.
### Giai đoạn 3: Gửi và lưu trữ trên Backend
1. FE đóng gói snapshot này vào trường `snapshot_json` và gửi tới API: `POST /projects/{id}/commits`.
2. Backend nhận được JSON này và lưu trữ nguyên vẹn vào cột `snapshot_json` của bảng `commits`.
*(Lưu ý: Backend hiện tại chưa xử lý cờ `delete` để cập nhật bảng liên kết vật lý `entity_geometries` dưới Database gốc khi duyệt commit, dẫn đến lỗi bất đồng bộ mà bạn đang gặp).*
### Giai đoạn 4: Tải Commit về (Load / Hydrate)
Khi Editor được mở lại hoặc pull commit mới nhất về, FE nhận được `snapshot_json` từ API. Hàm `toEditorSessionSnapshot()``normalizeEditorSnapshot()` sẽ thực hiện ngược lại:
1. **Lọc bỏ dòng đã xóa:**
Duyệt qua mảng `geometry_entity`, nếu gặp dòng có `operation === "delete"`, FE sẽ **bỏ qua ngay lập tức** (không nạp dòng này vào bộ nhớ baseline mới).
2. **Tái hợp nhất (Hydrate) vào GeoJSON:**
Duyệt qua các liên kết hợp lệ còn lại, nhóm chúng theo `geometry_id`, sau đó gán ngược danh sách `entity_ids` vào các Feature GeoJSON tương ứng để Editor có thể vẽ liên kết lên bản đồ.
---
## 3. Bảng tóm tắt trạng thái các cờ `operation`
| Trạng thái liên kết | Draft hiện tại | Baseline trước đó | Cờ `operation` trong Snapshot | FE xử lý khi Load |
| :--- | :---: | :---: | :---: | :--- |
| **Liên kết cũ giữ nguyên** | Có | Có | `reference` | Nạp vào bộ nhớ |
| **Tạo liên kết mới** | Có | Không | `binding` | Nạp vào bộ nhớ |
| **Gỡ liên kết cũ (Unbind)** | Không | Có | `delete` | **Bỏ qua (Không nạp)** |
---
## 4. Tại sao cơ chế này đôi khi gây bối rối?
- **Khác biệt giữa Snapshot JSON và DB gốc:** Snapshot JSON lưu trữ cả lịch sử thay đổi (cờ `delete`), trong khi Database gốc chỉ lưu trạng thái thực tế cuối cùng. Do đó, đọc trực tiếp API snapshot sẽ thấy dòng có cờ `delete`, nhưng chạy code FE hoặc DB đã duyệt thì liên kết đó phải mất đi.
- **Tính một lần (One-time instruction):** Dòng `"delete"` chỉ xuất hiện **đúng 1 lần** trong snapshot của commit thực hiện hành động xóa. Ở commit tiếp theo, do baseline đã lọc bỏ dòng này từ trước, nên diff sẽ không sinh ra dòng `"delete"` đó nữa.
+12 -4
View File
@@ -187,13 +187,21 @@ Kết luận:
### 4.3. Các REST API khác của Goong ### 4.3. Các REST API khác của Goong
Không dùng: Preview search hiện có dùng trực tiếp các REST API này từ browser:
- `Place/AutoComplete`
- `Place/Detail`
- `Geocode` reverse geocoding với `latlng=lat,lng`
Nguồn trong code:
- [goongPlaces.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/goongPlaces.ts:1)
- [PresentPlaceSearch.tsx](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/editor/PresentPlaceSearch.tsx:1)
Chưa dùng:
- geocoding
- autocomplete
- directions - directions
- distance matrix - distance matrix
- place details
- static map - static map
## 5. Backend cần làm gì ## 5. Backend cần làm gì
+183
View File
@@ -0,0 +1,183 @@
# Preview Place Search
Cập nhật: 2026-05-26.
Tính năng này cho phép người dùng search trong preview mode theo 2 chế độ:
- `Present`: search địa điểm hiện tại bằng Goong Place API.
- `History`: search entity lịch sử bằng API domain, sau đó dùng Goong reverse geocode để đặt nhãn hành chính hiện tại cho từng geometry candidate khi cần.
Đây là UI điều hướng tạm thời, không chỉnh sửa draft và không đi vào commit snapshot.
## Phạm vi
- Chỉ hiển thị trong preview mode:
- `preview`
- `replay_preview`
- Không hiển thị trong editor mode thường hoặc replay edit mode.
- Không tạo geometry, entity, wiki, replay, hay undo action.
- Khi thoát preview, state focus search được dọn khỏi UI.
## File liên quan
- `next.config.ts`
- Expose tạm `SEARCH_MAP_API_KEY` cho browser qua `env`.
- `src/uhm/api/goongPlaces.ts`
- Gọi Goong Place Autocomplete, Detail, và Geocode reverse.
- Chuẩn hóa response thành type nội bộ.
- `src/uhm/components/editor/PresentPlaceSearch.tsx`
- UI search/autocomplete với switch `Present` / `History`.
- Debounce request.
- Chọn kết quả và gọi callback focus.
- `src/uhm/api/geometries.ts`
- Gọi `/geometries/entity` cho search entity lịch sử.
- `src/uhm/components/map/mapUtils.ts`
- `getGeometryRepresentativePoint(...)` dùng polylabel cho polygon, midpoint cho line, và tọa độ thật cho point.
- `src/app/editor/[id]/page.tsx`
- Gắn UI vào preview overlay.
- Gọi `map.flyTo(...)` tới tọa độ địa điểm.
## Cấu hình
Hiện tại API key được đọc từ `.env.local`:
```env
SEARCH_MAP_API_KEY=...
```
Do đang dùng trực tiếp trên frontend, `next.config.ts` expose biến này:
```ts
env: {
SEARCH_MAP_API_KEY: process.env.SEARCH_MAP_API_KEY,
}
```
Sau khi thêm hoặc đổi key trong `.env.local`, phải restart Next dev server để value được bundle lại vào client.
## Luồng Present
1. User vào preview mode.
2. `PresentPlaceSearch` xuất hiện ở góc phải phía trên, ngang hàng với thanh zoom/map controls.
3. User để switch ở `Present` và nhập ít nhất 2 ký tự.
4. UI debounce khoảng 260ms rồi gọi:
```txt
GET https://rsapi.goong.io/Place/AutoComplete
```
5. User chọn một prediction.
6. FE gọi:
```txt
GET https://rsapi.goong.io/Place/Detail
```
7. Response detail được chuẩn hóa thành:
```ts
type PresentPlaceSelection = {
placeId: string;
name: string;
address: string;
lat: number;
lng: number;
};
```
8. Editor page:
- gọi `map.flyTo({ center: [lng, lat], zoom: Math.max(currentZoom, 13.5) })`,
- lưu `focusedPresentPlace` để clear trạng thái focus khi cần.
## Luồng History
1. User bấm switch `Present` để đổi sang `History`.
2. User nhập tên entity lịch sử.
3. UI debounce khoảng 260ms rồi gọi API domain:
```txt
GET /geometries/entity?name=...&limit=12
```
4. Nếu entity có đúng 1 geometry:
- focus luôn geometry đó.
5. Nếu entity có nhiều geometry:
- mở danh sách geometry phụ.
- với mỗi geometry, FE tính representative point:
- `Polygon/MultiPolygon`: dùng `polylabel`.
- `LineString/MultiLineString`: dùng midpoint theo chiều dài line.
- `Point/MultiPoint`: dùng tọa độ point hoặc trung bình các point.
- gọi Goong reverse geocode:
```txt
GET https://rsapi.goong.io/Geocode?latlng=<lat>,<lng>&api_key=...
```
- hiển thị nhãn hành chính hiện tại gần geometry đó.
6. Khi user chọn geometry:
- focus map vào bbox geometry bằng `fitMapToFeatureCollection`.
- nếu timeline filter đang bật và geometry có `time_start`, kéo preview timeline tới `time_start`.
- nếu geometry đó đang được render trên map, set selection cho geometry id tương ứng.
## UI behavior
- Thanh search nằm bên phải trên map preview.
- Nhãn `Present` / `History` trong hàng input là switch mode; không có cụm tab riêng phía trên.
- Khi replay preview đang mở wiki sidebar, search nhận `rightOffset = previewSidebarWidth + 48` để né sidebar.
- `Escape` đóng dropdown.
- `Enter` chọn kết quả đầu tiên nếu có.
- Nút `x` clear query hiện tại và clear focus search.
- Kết quả hiển thị dạng panel gọn, không dùng scrollbar nội bộ.
- Focus search chỉ di chuyển camera/select geometry; không vẽ marker/dấu chấm tạm trên map.
## State và undo
State bị đổi:
- `focusedPresentPlace`
- local state trong `PresentPlaceSearch`: query, results, loading, error
- local state của mode History: query, entity results, expanded entity, admin labels
Không đổi:
- `editor.mainDraft`
- `editor.replayDraft`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays`
- `selectedFeatureIds`
Do đó không cần undo action.
## Error handling
- Nếu thiếu `SEARCH_MAP_API_KEY`, input bị disable và hiển thị lỗi cấu hình.
- Nếu Goong trả response không hợp lệ hoặc không có tọa độ, UI hiển thị lỗi trong dropdown hoặc fallback label cho geometry.
- Request autocomplete được abort khi query đổi hoặc component unmount.
- Response cũ bị bỏ qua bằng sequence ref để tránh race khi user gõ nhanh.
## Giới hạn hiện tại
- API key đang expose trên browser. Đây chỉ là giải pháp tạm.
- Chưa có cache bền vững cho autocomplete/detail/reverse geocode.
- Chưa giới hạn theo quốc gia/khu vực.
- Chưa có keyboard navigation bằng mũi tên trong dropdown.
- History reverse geocode đang chạy trên browser theo từng geometry candidate khi mở entity nhiều geometry.
## Kế hoạch chuyển sang proxy BE
Khi backend có proxy, bỏ expose key khỏi `next.config.ts` và đổi `src/uhm/api/goongPlaces.ts` sang gọi endpoint nội bộ, ví dụ:
```txt
GET /api/map-search/place-autocomplete?input=...
GET /api/map-search/place-detail?place_id=...
GET /api/map-search/reverse-geocode?lat=...&lng=...
```
Backend giữ `SEARCH_MAP_API_KEY` ở server env và gọi Goong thay frontend. Response nên giữ shape tương thích với `PresentPlacePrediction`, `PresentPlaceSelection`, và reverse geocode label để không phải sửa UI.
Tham khảo thêm:
- `src/uhm/doc/goong_proxy_backend_guide.md`
- `src/uhm/doc/goong_apis_in_use.md`
@@ -255,9 +255,15 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const row = r as RawGeometryEntityRow; const row = r as RawGeometryEntityRow;
const geometry_id = getStringId(row.geometry_id); const geometry_id = getStringId(row.geometry_id);
const entity_id = typeof row.entity_id === "string" ? row.entity_id : ""; const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
const opRaw = typeof row.operation === "string" ? row.operation.trim() : "";
const operation: GeometryEntitySnapshot["operation"] =
opRaw === "delete" || opRaw === "binding" || opRaw === "reference"
? opRaw
: undefined;
return { return {
geometry_id, geometry_id,
entity_id, entity_id,
operation,
}; };
}) })
.filter((r) => r.geometry_id.length > 0 && r.entity_id.length > 0) .filter((r) => r.geometry_id.length > 0 && r.entity_id.length > 0)
+1 -1
View File
@@ -1,7 +1,7 @@
export const PATH_ARROW_ICON_ID = "path-arrow-icon"; export const PATH_ARROW_ICON_ID = "path-arrow-icon";
export const MAP_MIN_ZOOM = 2; export const MAP_MIN_ZOOM = 2;
export const MAP_MAX_ZOOM = 14; export const MAP_MAX_ZOOM = 20;
export const RASTER_BASE_SOURCE_ID = "rasterBase"; export const RASTER_BASE_SOURCE_ID = "rasterBase";
export const RASTER_BASE_LAYER_ID = "raster-base-layer"; export const RASTER_BASE_LAYER_ID = "raster-base-layer";