@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import Map, { type MapHandle } from "@/uhm/components/Map";
|
||||
import Map, { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map";
|
||||
import Editor from "@/uhm/components/Editor";
|
||||
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
@@ -423,6 +423,14 @@ function EditorPageContent() {
|
||||
const isViewerPreviewMode = mode === "preview";
|
||||
const isReplayPreviewMode = mode === "replay_preview";
|
||||
const isAnyPreviewMode = isViewerPreviewMode || isReplayPreviewMode;
|
||||
const isEditingGeometryOrReplay =
|
||||
mode === "draw" ||
|
||||
mode === "add-point" ||
|
||||
mode === "add-line" ||
|
||||
mode === "add-path" ||
|
||||
mode === "add-circle" ||
|
||||
mode === "replay" ||
|
||||
(mode === "select" && selectedFeatureIds.length > 0);
|
||||
const previewReturnModeRef = useRef<EditorMode>("select");
|
||||
const replayPreviewReturnRef = useRef<{
|
||||
mode: "replay" | "preview";
|
||||
@@ -1172,12 +1180,12 @@ function EditorPageContent() {
|
||||
}
|
||||
}, [isReplayPreviewMode, isViewerPreviewMode, exitReplayPreview, openSelectedViewerReplayPreview, viewerPreviewSelectedReplay]);
|
||||
|
||||
const handleMapFeatureClick = useCallback((payload: any) => {
|
||||
const handleMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => {
|
||||
previewLayoutRef.current?.handleFeatureClick(payload);
|
||||
}, []);
|
||||
|
||||
const handleMapHoverPopupContent = useCallback((payload: any) => {
|
||||
return previewLayoutRef.current?.getHoverPopupContent(payload) ?? null;
|
||||
const handleMapHoverPopupContent = useCallback((feature: Feature) => {
|
||||
return previewLayoutRef.current?.getHoverPopupContent(feature) ?? null;
|
||||
}, []);
|
||||
|
||||
const handleMapPlayPreviewReplay = useCallback(() => {
|
||||
@@ -2393,7 +2401,7 @@ function EditorPageContent() {
|
||||
const handleCreateFeature = useCallback((feature: Feature) => {
|
||||
editor.createFeature(feature);
|
||||
setSelectedFeatureIds([feature.properties.id]);
|
||||
}, [editor]);
|
||||
}, [editor, setSelectedFeatureIds]);
|
||||
|
||||
// Base draft for label lookup only. It must not decide which geometry is rendered.
|
||||
const labelContextBaseDraft = useMemo(() => {
|
||||
@@ -2746,7 +2754,7 @@ function EditorPageContent() {
|
||||
year={activeTimelineYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={false}
|
||||
disabled={false}
|
||||
disabled={isEditingGeometryOrReplay}
|
||||
statusText={null}
|
||||
filterEnabled={activeTimelineFilterEnabled}
|
||||
onFilterEnabledChange={setTimelineFilterEnabled}
|
||||
@@ -2923,49 +2931,6 @@ function isTypingTarget(target: EventTarget | null): boolean {
|
||||
return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity[]): FeatureCollection {
|
||||
if (!draft.features.length) return draft;
|
||||
|
||||
const entityById = new globalThis.Map<string, Entity>();
|
||||
for (const entity of entities || []) {
|
||||
const id = String(entity?.id || "").trim();
|
||||
if (!id) continue;
|
||||
entityById.set(id, entity);
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
features: draft.features.map((feature) => {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return feature;
|
||||
|
||||
const candidates = entityIds.map((id) => {
|
||||
const entity = entityById.get(id) || null;
|
||||
const name = String(entity?.name || id).trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
time_start: normalizeTimelineYearValue(entity?.time_start),
|
||||
time_end: normalizeTimelineYearValue(entity?.time_end),
|
||||
};
|
||||
}).filter((candidate) => candidate !== null);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
entity_id: entityIds[0] || null,
|
||||
entity_ids: entityIds,
|
||||
entity_name: candidates[0]?.name || null,
|
||||
entity_names: candidates.map((candidate) => candidate.name),
|
||||
entity_label_candidates: candidates,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEntityRefsForFeature(feature: Feature, entities: Entity[]): Entity[] {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return [];
|
||||
|
||||
+8
-2
@@ -1,4 +1,5 @@
|
||||
import api from "@/config/config";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import { API } from "../../api";
|
||||
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
|
||||
|
||||
@@ -62,7 +63,13 @@ export const apiResetPassword = async (payload: ResetPasswordPayload) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiGetCurrentUser = async (config?: any) => {
|
||||
type AuthRequestConfig = AxiosRequestConfig & {
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authToken?: string | null;
|
||||
};
|
||||
|
||||
export const apiGetCurrentUser = async (config?: AuthRequestConfig) => {
|
||||
const response = await api.get(API.User.CURRENT, config);
|
||||
return response?.data;
|
||||
};
|
||||
@@ -71,4 +78,3 @@ export const apiChangePassword = async (payload: ChangePasswordPayload) => {
|
||||
const response = await api.patch(API.User.CHANGE_PASSWORD, payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -44,3 +44,7 @@ export async function searchEntitiesByName(
|
||||
// API mới không có `/entities/search`, search qua query string.
|
||||
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchEntityById(id: string): Promise<Entity> {
|
||||
return requestJson<Entity>(`${API_ENDPOINTS.entities}/${id}`);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||
query.set("entity_id", params.entity_id);
|
||||
}
|
||||
|
||||
if (typeof params.hasBound === "boolean") {
|
||||
query.set("has_bound", String(params.hasBound));
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
@@ -71,6 +75,16 @@ export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promis
|
||||
return geometriesToFeatureCollection(rows);
|
||||
}
|
||||
|
||||
export async function fetchGeometriesByBoundWith(parentGeometryId: string): Promise<FeatureCollection> {
|
||||
const id = String(parentGeometryId || "").trim();
|
||||
if (!id) return { type: "FeatureCollection", features: [] };
|
||||
|
||||
const rows = await requestJson<GeometryRow[]>(
|
||||
`${API_ENDPOINTS.geometries}/bound-with/${encodeURIComponent(id)}`
|
||||
);
|
||||
return geometriesToFeatureCollection(rows);
|
||||
}
|
||||
|
||||
export async function searchGeometriesByEntityName(
|
||||
name: string,
|
||||
options?: { cursor?: string; limit?: number }
|
||||
@@ -129,6 +143,7 @@ type GeometryRow = {
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
replay_ids?: string[] | null;
|
||||
entity_id?: string | null;
|
||||
entity_name?: string | null;
|
||||
entity_description?: string | null;
|
||||
@@ -175,6 +190,7 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||
type: typeKey,
|
||||
time_start: row.time_start ?? null,
|
||||
time_end: row.time_end ?? null,
|
||||
replay_ids: normalizeStringArray(row.replay_ids),
|
||||
bound_with: boundWith,
|
||||
...(entityPreviews.length
|
||||
? {
|
||||
@@ -260,6 +276,13 @@ function normalizeNullableString(value: unknown): string | null {
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => normalizeString(item))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
+30
-9
@@ -1,5 +1,6 @@
|
||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||
import api from "@/config/config";
|
||||
import type { AxiosError, AxiosRequestConfig } from "axios";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -21,6 +22,8 @@ type RequestJsonOptions = {
|
||||
authToken?: string | null; // Override bearer token (used for refresh).
|
||||
};
|
||||
|
||||
type AuthAxiosRequestConfig = AxiosRequestConfig & RequestJsonOptions;
|
||||
|
||||
export async function requestJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
@@ -40,16 +43,17 @@ export async function requestJson<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.request({
|
||||
const requestConfig: AuthAxiosRequestConfig = {
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers: init?.headers as any,
|
||||
headers: normalizeRequestHeaders(init?.headers),
|
||||
// Custom properties for our axios interceptor.
|
||||
skipAuth: options?.skipAuth,
|
||||
authToken: options?.authToken,
|
||||
skipRefresh: options?.skipRefresh,
|
||||
} as any);
|
||||
};
|
||||
const response = await api.request(requestConfig);
|
||||
|
||||
const payload = response.data;
|
||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||
@@ -64,13 +68,14 @@ export async function requestJson<T>(
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
|
||||
const status = err.response?.status || 0;
|
||||
const payload = err.response?.data;
|
||||
const axiosError = err as AxiosError<unknown>;
|
||||
const status = axiosError.response?.status || 0;
|
||||
const payload = axiosError.response?.data;
|
||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||
const message = extractErrorMessage(payload, envelope) || err.message || "Request failed";
|
||||
const message = extractErrorMessage(payload, envelope) || (err instanceof Error ? err.message : "") || "Request failed";
|
||||
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
||||
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
||||
|
||||
@@ -99,16 +104,32 @@ function normalizeErrors(value: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
|
||||
const payloadRecord = isRecord(payload) ? payload : null;
|
||||
const msg =
|
||||
(typeof envelope?.message === "string" && envelope.message.trim()) ||
|
||||
(typeof (payload as any)?.message === "string" && String((payload as any).message).trim());
|
||||
(typeof payloadRecord?.message === "string" && payloadRecord.message.trim());
|
||||
if (msg) return msg;
|
||||
const errors = envelope?.errors ?? (payload as any)?.errors;
|
||||
const errors = envelope?.errors ?? payloadRecord?.errors;
|
||||
if (typeof errors === "string" && errors.trim()) return errors.trim();
|
||||
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function normalizeRequestHeaders(headers: RequestInit["headers"]): Record<string, string> | undefined {
|
||||
if (!headers) return undefined;
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers.map(([key, value]) => [key, value]));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown): string {
|
||||
if (typeof payload === "string") return payload;
|
||||
try {
|
||||
|
||||
@@ -57,6 +57,7 @@ type MapProps = {
|
||||
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
|
||||
hoverPopupEnabled?: boolean;
|
||||
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
|
||||
onHoverFeatureChange?: (feature: Feature | null) => void;
|
||||
allowFeatureSelection?: boolean;
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
@@ -98,6 +99,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
onFeatureClick,
|
||||
hoverPopupEnabled = false,
|
||||
getHoverPopupContent,
|
||||
onHoverFeatureChange,
|
||||
allowFeatureSelection = true,
|
||||
focusFeatureCollection = null,
|
||||
focusRequestKey = null,
|
||||
@@ -126,6 +128,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
// Ref callback click feature mới nhất cho tooltip/panel ngoài map.
|
||||
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
|
||||
const getHoverPopupContentRef = useRef<MapProps["getHoverPopupContent"]>(getHoverPopupContent);
|
||||
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
|
||||
// Ref callback create mới nhất khi drawing engine tạo feature.
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
// Ref callback add geometry global vào project mới nhất cho context menu select.
|
||||
@@ -151,6 +154,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
onSetModeRef.current = onSetMode;
|
||||
onFeatureClickRef.current = onFeatureClick;
|
||||
getHoverPopupContentRef.current = getHoverPopupContent;
|
||||
onHoverFeatureChangeRef.current = onHoverFeatureChange;
|
||||
onCreateRef.current = onCreateFeature;
|
||||
onAddFeatureToProjectRef.current = onAddFeatureToProject;
|
||||
onDeleteRef.current = onDeleteFeature;
|
||||
@@ -249,6 +253,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
enabled: hoverPopupEnabled,
|
||||
renderDraftRef,
|
||||
getContentRef: getHoverPopupContentRef,
|
||||
onHoverFeatureChangeRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -41,9 +41,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
<div style={{ marginBottom: 4 }}>Click vào hình trên map để Chọn (Select).</div>
|
||||
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
|
||||
<li><b>Giữ Shift + click</b>: Chọn nhiều hình hoặc bỏ chọn hình đang chọn</li>
|
||||
<li><b>Chuột phải</b>: Mở menu chỉnh sửa / xóa / duplicate / replay</li>
|
||||
<li>Trong chế độ Sửa đỉnh:
|
||||
<ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}>
|
||||
<li><b>Enter</b>: Lưu hình đã sửa</li>
|
||||
<li><b>Esc</b>: Hủy chỉnh sửa</li>
|
||||
<li><b>Delete</b>: Bật/Tắt chế độ Xóa đỉnh (click để xóa)</li>
|
||||
<li><b>Giữ Shift</b>: Bắt dính (Snap) điểm đang kéo</li>
|
||||
</ul>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PresentPlaceSelection,
|
||||
} from "@/uhm/api/goongPlaces";
|
||||
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
|
||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
|
||||
export type { PresentPlaceSelection } from "@/uhm/api/goongPlaces";
|
||||
|
||||
@@ -21,7 +22,7 @@ export type HistoricalGeometryFocusPayload = {
|
||||
adminLabel: string | null;
|
||||
};
|
||||
|
||||
type SearchMode = "present" | "history";
|
||||
type SearchMode = "present" | "history" | "wiki";
|
||||
|
||||
type AdminLabelState = {
|
||||
status: "loading" | "loaded" | "error";
|
||||
@@ -33,6 +34,7 @@ type Props = {
|
||||
focusedPlace: PresentPlaceSelection | null;
|
||||
onFocusPlace: (place: PresentPlaceSelection) => void;
|
||||
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
|
||||
onFocusWiki?: (wiki: Wiki) => void;
|
||||
onClearFocus: () => void;
|
||||
leftOffset?: number;
|
||||
style?: CSSProperties;
|
||||
@@ -42,11 +44,12 @@ export default function PresentPlaceSearch({
|
||||
focusedPlace,
|
||||
onFocusPlace,
|
||||
onFocusHistoricalGeometry,
|
||||
onFocusWiki,
|
||||
onClearFocus,
|
||||
leftOffset = 18,
|
||||
style,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<SearchMode>("present");
|
||||
const [mode, setMode] = useState<SearchMode>("history");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<PresentPlacePrediction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -61,14 +64,20 @@ export default function PresentPlaceSearch({
|
||||
const [selectingGeometryId, setSelectingGeometryId] = useState<string | null>(null);
|
||||
const [adminLabels, setAdminLabels] = useState<Record<string, AdminLabelState>>({});
|
||||
|
||||
const [wikiQuery, setWikiQuery] = useState("");
|
||||
const [wikiResults, setWikiResults] = useState<Wiki[]>([]);
|
||||
const [isWikiLoading, setIsWikiLoading] = useState(false);
|
||||
const [wikiError, setWikiError] = useState<string | null>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const requestSeqRef = useRef(0);
|
||||
const historicalRequestSeqRef = useRef(0);
|
||||
const wikiRequestSeqRef = useRef(0);
|
||||
const hasApiKey = hasSearchMapApiKey();
|
||||
|
||||
const activeQuery = mode === "present" ? query : historicalQuery;
|
||||
const activeError = mode === "present" ? error : historicalError;
|
||||
const activeLoading = mode === "present" ? isLoading : isHistoricalLoading;
|
||||
const activeQuery = mode === "present" ? query : mode === "history" ? historicalQuery : wikiQuery;
|
||||
const activeError = mode === "present" ? error : mode === "history" ? historicalError : wikiError;
|
||||
const activeLoading = mode === "present" ? isLoading : mode === "history" ? isHistoricalLoading : isWikiLoading;
|
||||
|
||||
const expandedItem = useMemo(() => {
|
||||
if (!expandedEntityId) return null;
|
||||
@@ -162,6 +171,43 @@ export default function PresentPlaceSearch({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historicalQuery, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "wiki") return;
|
||||
|
||||
const keyword = wikiQuery.trim();
|
||||
if (!keyword || keyword.length < 2) {
|
||||
setWikiResults([]);
|
||||
setIsWikiLoading(false);
|
||||
setWikiError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = wikiRequestSeqRef.current + 1;
|
||||
wikiRequestSeqRef.current = seq;
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsWikiLoading(true);
|
||||
setWikiError(null);
|
||||
searchWikisByTitle(keyword, { limit: 12 })
|
||||
.then((nextResults) => {
|
||||
if (wikiRequestSeqRef.current !== seq) return;
|
||||
setWikiResults(nextResults || []);
|
||||
setIsOpen(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (wikiRequestSeqRef.current !== seq) return;
|
||||
setWikiResults([]);
|
||||
setWikiError(err instanceof Error ? err.message : "Không search được wiki.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (wikiRequestSeqRef.current === seq) {
|
||||
setIsWikiLoading(false);
|
||||
}
|
||||
});
|
||||
}, 260);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [wikiQuery, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "history" || !expandedItem || expandedItem.geometries.length <= 1 || !hasApiKey) {
|
||||
return;
|
||||
@@ -255,26 +301,48 @@ export default function PresentPlaceSearch({
|
||||
setSelectingGeometryId(null);
|
||||
};
|
||||
|
||||
const selectWiki = (wiki: Wiki) => {
|
||||
if (onFocusWiki) {
|
||||
onFocusWiki(wiki);
|
||||
}
|
||||
setWikiQuery(wiki.title || "");
|
||||
setWikiResults([]);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
if (mode === "present") {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setError(null);
|
||||
} else {
|
||||
} else if (mode === "history") {
|
||||
setHistoricalQuery("");
|
||||
setHistoricalResults([]);
|
||||
setHistoricalError(null);
|
||||
setExpandedEntityId(null);
|
||||
} else {
|
||||
setWikiQuery("");
|
||||
setWikiResults([]);
|
||||
setWikiError(null);
|
||||
}
|
||||
setIsOpen(false);
|
||||
onClearFocus();
|
||||
};
|
||||
|
||||
const switchMode = (nextMode: SearchMode) => {
|
||||
const cycleMode = () => {
|
||||
let nextMode: SearchMode;
|
||||
if (mode === "history") {
|
||||
nextMode = "present";
|
||||
} else if (mode === "present") {
|
||||
nextMode = "wiki";
|
||||
} else {
|
||||
nextMode = "history";
|
||||
}
|
||||
setMode(nextMode);
|
||||
setIsOpen(true);
|
||||
setError(null);
|
||||
setHistoricalError(null);
|
||||
setWikiError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -295,20 +363,22 @@ export default function PresentPlaceSearch({
|
||||
<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"}
|
||||
onClick={cycleMode}
|
||||
title={`Switch search mode (current: ${mode})`}
|
||||
aria-label={`Switch search mode (current: ${mode})`}
|
||||
style={modeSwitchStyle}
|
||||
>
|
||||
{mode === "present" ? "Present" : "History"}
|
||||
{mode === "present" ? "Present" : mode === "history" ? "History" : "Wiki"}
|
||||
</button>
|
||||
<input
|
||||
value={activeQuery}
|
||||
onChange={(event) => {
|
||||
if (mode === "present") {
|
||||
setQuery(event.target.value);
|
||||
} else {
|
||||
} else if (mode === "history") {
|
||||
setHistoricalQuery(event.target.value);
|
||||
} else {
|
||||
setWikiQuery(event.target.value);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}}
|
||||
@@ -327,10 +397,20 @@ export default function PresentPlaceSearch({
|
||||
event.preventDefault();
|
||||
selectHistoricalEntity(historicalResults[0]);
|
||||
}
|
||||
if (mode === "wiki" && wikiResults[0]) {
|
||||
event.preventDefault();
|
||||
selectWiki(wikiResults[0]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={mode === "present" && !hasApiKey}
|
||||
placeholder={mode === "present" ? "Tìm địa điểm hiện tại" : "Tìm entity lịch sử"}
|
||||
placeholder={
|
||||
mode === "present"
|
||||
? "Tìm địa điểm hiện tại"
|
||||
: mode === "history"
|
||||
? "Tìm entity lịch sử"
|
||||
: "Tìm bài viết wiki"
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{activeQuery || focusedPlace ? (
|
||||
@@ -347,7 +427,7 @@ export default function PresentPlaceSearch({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults) ? (
|
||||
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults, wikiResults) ? (
|
||||
<div style={resultsPanelStyle}>
|
||||
{mode === "present" ? (
|
||||
<PresentResults
|
||||
@@ -358,7 +438,7 @@ export default function PresentPlaceSearch({
|
||||
selectingPlaceId={selectingPlaceId}
|
||||
onSelect={selectPrediction}
|
||||
/>
|
||||
) : (
|
||||
) : mode === "history" ? (
|
||||
<HistoricalResults
|
||||
isLoading={isHistoricalLoading}
|
||||
error={historicalError}
|
||||
@@ -371,6 +451,14 @@ export default function PresentPlaceSearch({
|
||||
onSelectEntity={selectHistoricalEntity}
|
||||
onSelectGeometry={selectHistoricalGeometry}
|
||||
/>
|
||||
) : (
|
||||
<WikiResults
|
||||
isLoading={isWikiLoading}
|
||||
error={wikiError}
|
||||
query={wikiQuery}
|
||||
results={wikiResults}
|
||||
onSelect={selectWiki}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -520,16 +608,61 @@ function HistoricalResults({
|
||||
);
|
||||
}
|
||||
|
||||
function WikiResults({
|
||||
isLoading,
|
||||
error,
|
||||
query,
|
||||
results,
|
||||
onSelect,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
query: string;
|
||||
results: Wiki[];
|
||||
onSelect: (wiki: Wiki) => void;
|
||||
}) {
|
||||
if (isLoading) return <div style={statusStyle}>Đang tìm wiki...</div>;
|
||||
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
|
||||
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không tìm thấy bài viết.</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((wiki) => (
|
||||
<button
|
||||
key={wiki.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(wiki)}
|
||||
style={resultButtonStyle}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={primaryResultTextStyle}>{wiki.title || wiki.slug || "Không có tiêu đề"}</span>
|
||||
{wiki.slug && (
|
||||
<span style={secondaryResultTextStyle}>/wiki/{wiki.slug}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRenderResults(
|
||||
mode: SearchMode,
|
||||
query: string,
|
||||
isLoading: boolean,
|
||||
error: string | null,
|
||||
presentResults: PresentPlacePrediction[],
|
||||
historicalResults: EntityGeometriesSearchItem[]
|
||||
historicalResults: EntityGeometriesSearchItem[],
|
||||
wikiResults: Wiki[]
|
||||
): boolean {
|
||||
if (isLoading || error || query.trim().length >= 2) return true;
|
||||
return mode === "present" ? presentResults.length > 0 : historicalResults.length > 0;
|
||||
if (mode === "present") return presentResults.length > 0;
|
||||
if (mode === "history") return historicalResults.length > 0;
|
||||
return wikiResults.length > 0;
|
||||
}
|
||||
|
||||
function formatAdminLabel(state: AdminLabelState | undefined): string {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useEffect, useMemo, useState, useCallback, useRef, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import type ReactQuill from "react-quill-new";
|
||||
import type {
|
||||
BattleReplay,
|
||||
GeoFunctionName,
|
||||
@@ -16,10 +17,12 @@ import { Panel } from "./Panel";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
import { fetchWikiBySlug, searchWikisByTitle } from "@/uhm/api/wikis";
|
||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
const ReactQuillEditor = dynamic<any>(() => import("react-quill-new"), {
|
||||
type ReactQuillEditorProps = ComponentProps<typeof ReactQuill>;
|
||||
|
||||
const ReactQuillEditor = dynamic<ReactQuillEditorProps>(() => import("react-quill-new"), {
|
||||
ssr: false,
|
||||
loading: () => <div style={{ height: "120px", background: "#0b1220", borderRadius: "8px" }} className="animate-pulse" />,
|
||||
});
|
||||
@@ -46,6 +49,25 @@ type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function
|
||||
type ActionValue = string | boolean | string[];
|
||||
type ActionFormValues = Record<string, ActionValue>;
|
||||
type AnyReplayAction = ReplayAction<UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName>;
|
||||
type QuillRange = {
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
type QuillLike = {
|
||||
getSelection?: () => QuillRange | null;
|
||||
getFormat?: (indexOrRange?: number | QuillRange, length?: number) => Record<string, unknown>;
|
||||
getText?: (index: number, length: number) => string;
|
||||
setSelection?: (index: number, length: number, source?: string) => void;
|
||||
formatText?: (index: number, length: number, name: string, value: unknown, source?: string) => void;
|
||||
insertText?: (index: number, text: string, formats?: Record<string, unknown>, source?: string) => void;
|
||||
format?: (name: string, value: unknown, source?: string) => void;
|
||||
};
|
||||
type WikiLinkCandidate = {
|
||||
key: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
source: "local" | "global";
|
||||
};
|
||||
|
||||
type ActionFieldConfig = {
|
||||
name: string;
|
||||
@@ -157,7 +179,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
||||
],
|
||||
create: () => ({ function_name: "set_dialog", params: [{ text: "", image_url: "" }] }),
|
||||
deserialize: (params) => {
|
||||
const data: any = params[0];
|
||||
const data = params[0] as { text?: unknown; image_url?: unknown } | null | undefined;
|
||||
if (data === null) {
|
||||
return {
|
||||
clear: true,
|
||||
@@ -175,7 +197,10 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
||||
if (values.clear) {
|
||||
return [null];
|
||||
}
|
||||
const data: any = {
|
||||
const data: {
|
||||
text: string;
|
||||
image_url?: string;
|
||||
} = {
|
||||
text: asString(values.text),
|
||||
};
|
||||
if (values.image_url) {
|
||||
@@ -202,8 +227,8 @@ export default function ReplayEffectsSidebar({
|
||||
|
||||
// Quill: custom link UI (link-to-wiki by slug).
|
||||
const wikiLinkIntentRef = useRef<{
|
||||
quill: any;
|
||||
range: any;
|
||||
quill: QuillLike;
|
||||
range: QuillRange | null;
|
||||
existingHref: string | null;
|
||||
} | null>(null);
|
||||
|
||||
@@ -211,12 +236,12 @@ export default function ReplayEffectsSidebar({
|
||||
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
|
||||
const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title");
|
||||
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
|
||||
const [globalWikiResults, setGlobalWikiResults] = useState<any[]>([]);
|
||||
const [globalWikiResults, setGlobalWikiResults] = useState<Wiki[]>([]);
|
||||
const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false);
|
||||
const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(null);
|
||||
const globalWikiSearchRequestRef = useRef(0);
|
||||
|
||||
const handleLinkClick = useCallback((quill: any) => {
|
||||
const handleLinkClick = useCallback((quill: QuillLike | null | undefined) => {
|
||||
if (!quill) return;
|
||||
const range = quill.getSelection?.() ?? null;
|
||||
const existingHref =
|
||||
@@ -304,9 +329,9 @@ export default function ReplayEffectsSidebar({
|
||||
};
|
||||
}, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]);
|
||||
|
||||
const globalWikiLinkCandidates = useMemo(() => {
|
||||
const globalWikiLinkCandidates = useMemo<WikiLinkCandidate[]>(() => {
|
||||
if (!isWikiLinkOpen) return [];
|
||||
const out: any[] = [];
|
||||
const out: WikiLinkCandidate[] = [];
|
||||
for (const row of globalWikiResults || []) {
|
||||
const slug = typeof row?.slug === "string" ? row.slug.trim() : "";
|
||||
if (!slug.length) continue;
|
||||
@@ -1158,7 +1183,7 @@ function ActionGroupEditor<T extends string>({
|
||||
createOnSelect?: boolean;
|
||||
emptyOptionLabel?: string;
|
||||
onUpdateActions: (nextActions: ReplayAction<T>[], label: string) => void;
|
||||
onLinkClick?: (quill: any) => void;
|
||||
onLinkClick?: (quill: QuillLike | null | undefined) => void;
|
||||
resetKey?: string;
|
||||
}) {
|
||||
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
|
||||
@@ -1173,7 +1198,7 @@ function ActionGroupEditor<T extends string>({
|
||||
);
|
||||
|
||||
const lastResetKeyRef = useRef<string | undefined>(undefined);
|
||||
const lastLoadedActionsRef = useRef<any>(null);
|
||||
const lastLoadedActionsRef = useRef<ReplayAction<T>[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resetKeyChanged = resetKey !== lastResetKeyRef.current;
|
||||
@@ -1183,18 +1208,21 @@ function ActionGroupEditor<T extends string>({
|
||||
lastResetKeyRef.current = resetKey;
|
||||
lastLoadedActionsRef.current = actions;
|
||||
|
||||
if (actions.length > 0) {
|
||||
const first = actions[0];
|
||||
setComposerFunctionName(first.function_name);
|
||||
const def = definitions[first.function_name];
|
||||
if (def) {
|
||||
setComposerDraftValues(def.deserialize(first.params));
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (actions.length > 0) {
|
||||
const first = actions[0];
|
||||
setComposerFunctionName(first.function_name);
|
||||
const def = definitions[first.function_name];
|
||||
if (def) {
|
||||
setComposerDraftValues(def.deserialize(first.params));
|
||||
}
|
||||
} else {
|
||||
const defaultFun = createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T);
|
||||
setComposerFunctionName(defaultFun);
|
||||
setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun));
|
||||
}
|
||||
} else {
|
||||
const defaultFun = createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T);
|
||||
setComposerFunctionName(defaultFun);
|
||||
setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun));
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [actions, definitions, createOnSelect, functionNames, resetKey]);
|
||||
|
||||
const composerDefinition = composerFunctionName
|
||||
@@ -1349,7 +1377,7 @@ function FieldInput({
|
||||
geometryChoices: Choice[];
|
||||
wikiChoices: Choice[];
|
||||
onChange: (nextValue: ActionValue) => void;
|
||||
onLinkClick?: (quill: any) => void;
|
||||
onLinkClick?: (quill: QuillLike | null | undefined) => void;
|
||||
}) {
|
||||
const onLinkClickRef = useRef(onLinkClick);
|
||||
useEffect(() => {
|
||||
@@ -1366,7 +1394,7 @@ function FieldInput({
|
||||
["clean"],
|
||||
],
|
||||
handlers: {
|
||||
link: function (this: { quill?: any }) {
|
||||
link: function (this: { quill?: QuillLike }) {
|
||||
onLinkClickRef.current?.(this?.quill);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function ReplayTimelineSidebar({
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
function getBackgroundGeometryIdsFromReplay(replay: any): Set<string> {
|
||||
function getBackgroundGeometryIdsFromReplay(replay: BattleReplay | null): Set<string> {
|
||||
const bgIds = new Set<string>();
|
||||
if (!replay || !Array.isArray(replay.detail)) return bgIds;
|
||||
for (const stage of replay.detail) {
|
||||
|
||||
@@ -318,7 +318,7 @@ export function decorateLineFeaturesWithLabels(
|
||||
return changed ? { ...fc, features: nextFeatures } : fc;
|
||||
}
|
||||
|
||||
const polygonLabelFeaturesCache = new WeakMap<any, { label: string; feature: Feature }>();
|
||||
const polygonLabelFeaturesCache = new WeakMap<Feature, { label: string; feature: Feature }>();
|
||||
|
||||
export function buildPolygonLabelFeatureCollection(
|
||||
fc: FeatureCollection,
|
||||
@@ -484,7 +484,7 @@ export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate |
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathArrowGeometriesCache = new WeakMap<any, Geometry[]>();
|
||||
const pathArrowGeometriesCache = new WeakMap<Geometry, Geometry[]>();
|
||||
|
||||
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
@@ -1161,7 +1161,7 @@ function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const polygonLabelPointCache = new WeakMap<any, Coordinate | null>();
|
||||
const polygonLabelPointCache = new WeakMap<Geometry, Coordinate | null>();
|
||||
|
||||
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
|
||||
if (polygonLabelPointCache.has(geometry)) {
|
||||
@@ -1282,7 +1282,7 @@ export function decorateFeaturesWithEntityColors(fc: FeatureCollection): Feature
|
||||
}
|
||||
}
|
||||
|
||||
if ((feature.properties as any).entity_color === entity_color) {
|
||||
if (feature.properties.entity_color === entity_color) {
|
||||
return feature;
|
||||
}
|
||||
changed = true;
|
||||
|
||||
@@ -7,7 +7,14 @@ export type MapHoverPopupContent = {
|
||||
key?: string;
|
||||
rows: Array<{
|
||||
title: string;
|
||||
titleTone?: "danger" | "default";
|
||||
isGroupHeader?: boolean;
|
||||
description?: string | null;
|
||||
titleTimeRange?: string | null;
|
||||
titleTimeRangeTone?: "success" | "muted";
|
||||
separatorBefore?: boolean;
|
||||
quote?: string | null;
|
||||
quoteTone?: "danger" | "default";
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
};
|
||||
@@ -17,6 +24,7 @@ type UseMapHoverPopupProps = {
|
||||
enabled: boolean;
|
||||
renderDraftRef: React.MutableRefObject<FeatureCollection>;
|
||||
getContentRef: React.MutableRefObject<((feature: Feature) => MapHoverPopupContent | null) | undefined>;
|
||||
onHoverFeatureChangeRef?: React.MutableRefObject<((feature: Feature | null) => void) | undefined>;
|
||||
};
|
||||
|
||||
export function useMapHoverPopup({
|
||||
@@ -24,6 +32,7 @@ export function useMapHoverPopup({
|
||||
enabled,
|
||||
renderDraftRef,
|
||||
getContentRef,
|
||||
onHoverFeatureChangeRef,
|
||||
}: UseMapHoverPopupProps) {
|
||||
const enabledRef = useRef(enabled);
|
||||
|
||||
@@ -45,12 +54,137 @@ export function useMapHoverPopup({
|
||||
let hoveredKey: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let pendingEvent: maplibregl.MapMouseEvent | null = null;
|
||||
let lastMapMouseEvent: maplibregl.MapMouseEvent | null = null;
|
||||
let currentContent: MapHoverPopupContent | null = null;
|
||||
let selectedRowIndex = 0;
|
||||
let selectionVisible = false;
|
||||
let lastSelectedRowClick: (() => void) | null = null;
|
||||
let lastSelectedRowAt = 0;
|
||||
let hoverLayerIds = getHoverLayerIds(map);
|
||||
let activeFeatureId: string | null = null;
|
||||
let featureLookupDraft: FeatureCollection | null = null;
|
||||
let featureById = new Map<string, Feature>();
|
||||
|
||||
const refreshHoverLayerIds = () => {
|
||||
hoverLayerIds = getHoverLayerIds(map);
|
||||
};
|
||||
|
||||
const getSourceFeatureById = (id: string) => {
|
||||
const draft = renderDraftRef.current;
|
||||
if (draft !== featureLookupDraft) {
|
||||
featureLookupDraft = draft;
|
||||
featureById = new Map(draft.features.map((item) => [String(item.properties.id), item]));
|
||||
}
|
||||
return featureById.get(id) || null;
|
||||
};
|
||||
|
||||
const removePopup = () => {
|
||||
hoveredKey = null;
|
||||
currentContent = null;
|
||||
selectedRowIndex = 0;
|
||||
selectionVisible = false;
|
||||
if (activeFeatureId !== null) {
|
||||
activeFeatureId = null;
|
||||
onHoverFeatureChangeRef?.current?.(null);
|
||||
}
|
||||
popup.remove();
|
||||
};
|
||||
|
||||
const getCurrentRows = () => getSelectableRows(currentContent);
|
||||
|
||||
const syncSelectedRow = () => {
|
||||
const rows = getCurrentRows();
|
||||
if (!rows.length) return;
|
||||
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
|
||||
lastSelectedRowClick = rows[selectedRowIndex]?.onClick || null;
|
||||
lastSelectedRowAt = Date.now();
|
||||
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible);
|
||||
};
|
||||
|
||||
const cyclePopupRow = (direction: "next" | "prev") => {
|
||||
const rows = getCurrentRows();
|
||||
if (!rows.length) return;
|
||||
selectedRowIndex += direction === "prev" ? -1 : 1;
|
||||
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
|
||||
syncSelectedRow();
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.shiftKey) return;
|
||||
if (isEditableEventTarget(event.target)) return;
|
||||
|
||||
const rows = getCurrentRows();
|
||||
if (!rows.length) return;
|
||||
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
const insidePopup = Boolean(target?.closest(".uhm-map-hover-popup"));
|
||||
const insideMap = target ? map.getContainer().contains(target) : false;
|
||||
if (!insidePopup && !insideMap) return;
|
||||
|
||||
const direction = event.deltaY > 0 ? "next" : event.deltaY < 0 ? "prev" : null;
|
||||
if (!direction) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectionVisible = true;
|
||||
cyclePopupRow(direction);
|
||||
};
|
||||
|
||||
const onCommitPopupRow = () => {
|
||||
const rows = getCurrentRows();
|
||||
const selectedRowClick = rows.length
|
||||
? rows[wrapIndex(selectedRowIndex, rows.length)]?.onClick
|
||||
: (Date.now() - lastSelectedRowAt < 1200 ? lastSelectedRowClick : null);
|
||||
selectedRowClick?.();
|
||||
if (rows.length || selectedRowClick) {
|
||||
removePopup();
|
||||
}
|
||||
};
|
||||
|
||||
const requestPopupUpdateFromLastMouseEvent = () => {
|
||||
if (!lastMapMouseEvent || !enabledRef.current) return;
|
||||
pendingEvent = lastMapMouseEvent;
|
||||
if (frameId !== null) return;
|
||||
frameId = window.requestAnimationFrame(updatePopup);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") {
|
||||
if (event.repeat) return;
|
||||
if (isEditableEventTarget(event.target)) return;
|
||||
selectionVisible = true;
|
||||
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible);
|
||||
requestPopupUpdateFromLastMouseEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") return;
|
||||
if (isEditableEventTarget(event.target)) return;
|
||||
if (!getCurrentRows().length) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCommitPopupRow();
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Shift") return;
|
||||
if (isEditableEventTarget(event.target)) return;
|
||||
|
||||
if (!getCurrentRows().length && lastMapMouseEvent) {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
pendingEvent = pendingEvent || lastMapMouseEvent;
|
||||
updatePopup();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCommitPopupRow();
|
||||
};
|
||||
|
||||
const updatePopup = () => {
|
||||
frameId = null;
|
||||
const event = pendingEvent;
|
||||
@@ -61,13 +195,19 @@ export function useMapHoverPopup({
|
||||
return;
|
||||
}
|
||||
|
||||
const layerIds = getHoverLayerIds(map);
|
||||
if (!layerIds.length) {
|
||||
if (!hoverLayerIds.length) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const features = map.queryRenderedFeatures(event.point, { layers: layerIds }) as maplibregl.MapGeoJSONFeature[];
|
||||
let features: maplibregl.MapGeoJSONFeature[];
|
||||
try {
|
||||
features = map.queryRenderedFeatures(event.point, { layers: hoverLayerIds }) as maplibregl.MapGeoJSONFeature[];
|
||||
} catch {
|
||||
refreshHoverLayerIds();
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
if (!features.length) {
|
||||
removePopup();
|
||||
return;
|
||||
@@ -81,11 +221,15 @@ export function useMapHoverPopup({
|
||||
}
|
||||
|
||||
const id = String(rawId);
|
||||
const sourceFeature = renderDraftRef.current.features.find((item) => String(item.properties.id) === id);
|
||||
const sourceFeature = getSourceFeatureById(id);
|
||||
if (!sourceFeature) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
if (id !== activeFeatureId) {
|
||||
activeFeatureId = id;
|
||||
onHoverFeatureChangeRef?.current?.(sourceFeature);
|
||||
}
|
||||
|
||||
const content = getContentRef.current?.(sourceFeature) || null;
|
||||
if (!content?.rows?.some((row) => row.title.trim())) {
|
||||
@@ -93,23 +237,40 @@ export function useMapHoverPopup({
|
||||
return;
|
||||
}
|
||||
|
||||
const contentKey = `${id}:${content.key || content.rows.map((row) => `${row.title}:${row.quote || ""}`).join("|")}`;
|
||||
const contentKey = buildContentKey(id, content);
|
||||
const contentChanged = contentKey !== hoveredKey;
|
||||
const shouldStylePopup = contentChanged || !popup.isOpen();
|
||||
if (contentKey !== hoveredKey) {
|
||||
hoveredKey = contentKey;
|
||||
popup.setDOMContent(buildPopupNode(content));
|
||||
currentContent = content;
|
||||
selectedRowIndex = 0;
|
||||
if (!selectionVisible) {
|
||||
selectionVisible = Boolean(event.originalEvent?.shiftKey);
|
||||
}
|
||||
popup.setDOMContent(buildPopupNode(content, selectedRowIndex, selectionVisible));
|
||||
}
|
||||
|
||||
popup.setLngLat(event.lngLat).addTo(map);
|
||||
stylePopupChrome(popup);
|
||||
popup.setLngLat(event.lngLat);
|
||||
if (!popup.isOpen()) {
|
||||
popup.addTo(map);
|
||||
}
|
||||
if (shouldStylePopup) {
|
||||
stylePopupChrome(popup);
|
||||
}
|
||||
if (contentChanged) {
|
||||
syncSelectedRow();
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: maplibregl.MapMouseEvent) => {
|
||||
lastMapMouseEvent = event;
|
||||
pendingEvent = event;
|
||||
if (frameId !== null) return;
|
||||
frameId = window.requestAnimationFrame(updatePopup);
|
||||
};
|
||||
|
||||
const onMouseOut = () => {
|
||||
lastMapMouseEvent = null;
|
||||
pendingEvent = null;
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
@@ -122,6 +283,10 @@ export function useMapHoverPopup({
|
||||
map.on("mouseout", onMouseOut);
|
||||
map.on("dragstart", removePopup);
|
||||
map.on("zoomstart", removePopup);
|
||||
map.on("styledata", refreshHoverLayerIds);
|
||||
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.addEventListener("keyup", onKeyUp, { capture: true });
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
@@ -131,9 +296,13 @@ export function useMapHoverPopup({
|
||||
map.off("mouseout", onMouseOut);
|
||||
map.off("dragstart", removePopup);
|
||||
map.off("zoomstart", removePopup);
|
||||
map.off("styledata", refreshHoverLayerIds);
|
||||
window.removeEventListener("wheel", onWheel, { capture: true });
|
||||
window.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.removeEventListener("keyup", onKeyUp, { capture: true });
|
||||
popup.remove();
|
||||
};
|
||||
}, [getContentRef, mapRef, renderDraftRef]);
|
||||
}, [getContentRef, mapRef, onHoverFeatureChangeRef, renderDraftRef]);
|
||||
}
|
||||
|
||||
function getHoverLayerIds(map: maplibregl.Map): string[] {
|
||||
@@ -150,7 +319,17 @@ function getHoverLayerIds(map: maplibregl.Map): string[] {
|
||||
}
|
||||
|
||||
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
|
||||
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
|
||||
let best = features[0];
|
||||
let bestPriority = featureSelectPriority(best);
|
||||
for (let index = 1; index < features.length; index += 1) {
|
||||
const candidate = features[index];
|
||||
const priority = featureSelectPriority(candidate);
|
||||
if (priority > bestPriority) {
|
||||
best = candidate;
|
||||
bestPriority = priority;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
|
||||
@@ -165,8 +344,12 @@ function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
function buildPopupNode(content: MapHoverPopupContent, selectedRowIndex: number, selectionVisible: boolean): HTMLElement {
|
||||
ensureHoverPopupScrollbarStyle();
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "uhm-map-hover-popup-body";
|
||||
root.dataset.hoverPopupScrollRoot = "true";
|
||||
root.style.width = "320px";
|
||||
root.style.maxWidth = "calc(100vw - 2rem)";
|
||||
root.style.maxHeight = "300px";
|
||||
@@ -181,13 +364,25 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "grid";
|
||||
grid.style.gap = "8px";
|
||||
grid.style.gap = "5px";
|
||||
root.appendChild(grid);
|
||||
|
||||
for (const row of content.rows) {
|
||||
const rows = normalizeRows(content);
|
||||
let selectableRowIndex = 0;
|
||||
rows.forEach((row) => {
|
||||
const titleText = row.title.trim();
|
||||
if (!titleText) continue;
|
||||
|
||||
if (row.separatorBefore) {
|
||||
const separator = document.createElement("div");
|
||||
separator.style.height = "1px";
|
||||
separator.style.margin = "2px 0";
|
||||
separator.style.background = "rgba(255, 255, 255, 0.16)";
|
||||
separator.style.pointerEvents = "none";
|
||||
grid.appendChild(separator);
|
||||
}
|
||||
|
||||
const isGroupHeader = Boolean(row.isGroupHeader);
|
||||
const rowSelectionIndex = isGroupHeader ? null : selectableRowIndex++;
|
||||
const card: HTMLButtonElement | HTMLDivElement = row.onClick
|
||||
? document.createElement("button")
|
||||
: document.createElement("div");
|
||||
@@ -199,50 +394,89 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
row.onClick?.();
|
||||
};
|
||||
}
|
||||
card.style.width = "100%";
|
||||
card.style.border = "1px solid rgba(255, 255, 255, 0.10)";
|
||||
card.style.width = isGroupHeader ? "100%" : "calc(100% - 18px)";
|
||||
card.style.marginLeft = isGroupHeader ? "0" : "18px";
|
||||
card.style.border = isGroupHeader ? "0" : "1px solid transparent";
|
||||
card.style.borderRadius = "0px";
|
||||
card.style.background = "rgba(255, 255, 255, 0.03)";
|
||||
card.style.padding = "12px";
|
||||
card.style.background = "transparent";
|
||||
card.style.padding = isGroupHeader ? "8px 2px 3px" : "7px 9px";
|
||||
card.style.textAlign = "left";
|
||||
card.style.font = "inherit";
|
||||
card.style.cursor = row.onClick ? "pointer" : "default";
|
||||
card.style.display = "block";
|
||||
card.style.outline = "none";
|
||||
card.style.appearance = "none";
|
||||
card.style.webkitAppearance = "none";
|
||||
card.style.transition = "border-color 140ms ease, background 140ms ease";
|
||||
if (rowSelectionIndex !== null) {
|
||||
card.dataset.hoverPopupRowIndex = String(rowSelectionIndex);
|
||||
}
|
||||
if (isGroupHeader) {
|
||||
card.dataset.hoverPopupGroupHeader = "true";
|
||||
}
|
||||
if (row.onClick) {
|
||||
card.onmouseenter = () => {
|
||||
card.style.borderColor = "rgba(56, 189, 248, 0.40)";
|
||||
card.style.background = "rgba(14, 165, 233, 0.10)";
|
||||
if (card.dataset.hoverPopupSelected === "true") return;
|
||||
card.style.borderColor = "transparent";
|
||||
card.style.background = "rgba(14, 165, 233, 0.08)";
|
||||
};
|
||||
card.onmouseleave = () => {
|
||||
card.style.borderColor = "rgba(255, 255, 255, 0.10)";
|
||||
card.style.background = "rgba(255, 255, 255, 0.03)";
|
||||
if (card.dataset.hoverPopupSelected === "true") {
|
||||
applyPopupRowStyle(card, true);
|
||||
return;
|
||||
}
|
||||
card.style.borderColor = "transparent";
|
||||
card.style.background = "transparent";
|
||||
};
|
||||
}
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = titleText;
|
||||
title.style.fontSize = "14px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "20px";
|
||||
title.style.color = "#ffffff";
|
||||
title.style.fontSize = isGroupHeader ? "14px" : "13px";
|
||||
title.style.fontWeight = isGroupHeader ? "800" : "650";
|
||||
title.style.lineHeight = isGroupHeader ? "20px" : "18px";
|
||||
title.style.color = row.titleTone === "danger" ? "#f87171" : (isGroupHeader ? "#ffffff" : "#e5edf7");
|
||||
title.style.overflow = "hidden";
|
||||
title.style.textOverflow = "ellipsis";
|
||||
title.style.whiteSpace = "nowrap";
|
||||
const descriptionText = row.description?.trim();
|
||||
if (row.titleTimeRange?.trim()) {
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = titleText;
|
||||
title.appendChild(nameSpan);
|
||||
|
||||
const rangeSpan = document.createElement("span");
|
||||
rangeSpan.textContent = ` (${row.titleTimeRange.trim()})`;
|
||||
rangeSpan.style.color = row.titleTimeRangeTone === "success" ? "#34d399" : "#94a3b8";
|
||||
rangeSpan.style.fontWeight = "700";
|
||||
title.appendChild(rangeSpan);
|
||||
} else {
|
||||
title.textContent = titleText;
|
||||
}
|
||||
card.appendChild(title);
|
||||
|
||||
if (isGroupHeader && descriptionText) {
|
||||
const descriptionWrap = document.createElement("div");
|
||||
descriptionWrap.className = "uhm-map-hover-popup-description-marquee";
|
||||
descriptionWrap.title = descriptionText;
|
||||
|
||||
const descriptionSpan = document.createElement("span");
|
||||
descriptionSpan.textContent = descriptionText;
|
||||
descriptionWrap.appendChild(descriptionSpan);
|
||||
card.appendChild(descriptionWrap);
|
||||
}
|
||||
|
||||
const quoteText = row.quote?.trim();
|
||||
if (quoteText) {
|
||||
const quote = document.createElement("div");
|
||||
quote.textContent = quoteText;
|
||||
quote.style.marginTop = "8px";
|
||||
quote.style.paddingLeft = "10px";
|
||||
quote.style.marginTop = "5px";
|
||||
quote.style.paddingLeft = "8px";
|
||||
quote.style.paddingRight = "4px";
|
||||
quote.style.borderLeft = "3px solid rgba(56, 189, 248, 0.40)";
|
||||
quote.style.fontSize = "14px";
|
||||
quote.style.borderLeft = `2px solid ${row.quoteTone === "danger" ? "rgba(248, 113, 113, 0.58)" : "rgba(56, 189, 248, 0.34)"}`;
|
||||
quote.style.fontSize = "13px";
|
||||
quote.style.fontStyle = "italic";
|
||||
quote.style.lineHeight = "20px";
|
||||
quote.style.color = "#cbd5e1";
|
||||
quote.style.lineHeight = "18px";
|
||||
quote.style.color = row.quoteTone === "danger" ? "#f87171" : "#b8c4d4";
|
||||
quote.style.display = "-webkit-box";
|
||||
quote.style.webkitLineClamp = "4";
|
||||
quote.style.webkitBoxOrient = "vertical";
|
||||
@@ -252,11 +486,180 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
}
|
||||
|
||||
grid.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
updatePopupNodeRowSelection(root, selectedRowIndex, selectionVisible);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildContentKey(featureId: string, content: MapHoverPopupContent): string {
|
||||
if (content.key) return `${featureId}:${content.key}`;
|
||||
return `${featureId}:${content.rows
|
||||
.map((row) => [
|
||||
row.separatorBefore ? "sep" : "",
|
||||
row.isGroupHeader ? "group" : "",
|
||||
row.title,
|
||||
row.titleTone || "",
|
||||
row.description || "",
|
||||
row.titleTimeRange || "",
|
||||
row.titleTimeRangeTone || "",
|
||||
row.quote || "",
|
||||
].join(":"))
|
||||
.join("|")}`;
|
||||
}
|
||||
|
||||
function ensureHoverPopupScrollbarStyle() {
|
||||
const styleId = "uhm-map-hover-popup-scrollbar-style";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.uhm-map-hover-popup-body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-body::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb {
|
||||
min-height: 36px;
|
||||
border: 2px solid rgba(2, 6, 23, 0.95);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.96), rgba(56, 189, 248, 0.72));
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-body button,
|
||||
.uhm-map-hover-popup-body button:focus,
|
||||
.uhm-map-hover-popup-body button:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-description-marquee {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-description-marquee > span {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.uhm-map-hover-popup-description-marquee:hover > span {
|
||||
animation: uhm-hover-popup-marquee 8s linear infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes uhm-hover-popup-marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100% + 100px)); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function isEditableEventTarget(target: EventTarget | null): boolean {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
if (!element) return false;
|
||||
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
||||
}
|
||||
|
||||
function normalizeRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
|
||||
return (content?.rows || []).filter((row) => row.title.trim());
|
||||
}
|
||||
|
||||
function getSelectableRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
|
||||
return normalizeRows(content).filter((row) => !row.isGroupHeader);
|
||||
}
|
||||
|
||||
function wrapIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return ((index % length) + length) % length;
|
||||
}
|
||||
|
||||
function updatePopupRowSelection(popup: maplibregl.Popup, selectedRowIndex: number, selectionVisible: boolean) {
|
||||
updatePopupNodeRowSelection(popup.getElement() || null, selectedRowIndex, selectionVisible);
|
||||
}
|
||||
|
||||
function updatePopupNodeRowSelection(root: HTMLElement | null, selectedRowIndex: number, selectionVisible: boolean) {
|
||||
if (!root) return;
|
||||
const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-hover-popup-row-index]"));
|
||||
let selectedCard: HTMLElement | null = null;
|
||||
for (const card of cards) {
|
||||
const rowIndex = Number(card.dataset.hoverPopupRowIndex);
|
||||
const selected = selectionVisible && rowIndex === selectedRowIndex;
|
||||
card.dataset.hoverPopupSelected = selected ? "true" : "false";
|
||||
applyPopupRowStyle(card, selected);
|
||||
if (selected) {
|
||||
selectedCard = card;
|
||||
}
|
||||
}
|
||||
if (selectedCard) {
|
||||
ensurePopupRowVisible(selectedCard);
|
||||
window.requestAnimationFrame(() => ensurePopupRowVisible(selectedCard));
|
||||
}
|
||||
}
|
||||
|
||||
function applyPopupRowStyle(card: HTMLElement, selected: boolean) {
|
||||
card.style.borderColor = "transparent";
|
||||
card.style.background = selected ? "rgba(14, 165, 233, 0.11)" : "transparent";
|
||||
card.style.boxShadow = selected ? "inset 2px 0 0 rgba(56, 189, 248, 0.95)" : "none";
|
||||
}
|
||||
|
||||
function ensurePopupRowVisible(card: HTMLElement) {
|
||||
const scrollRoot = card.closest<HTMLElement>("[data-hover-popup-scroll-root='true']");
|
||||
if (!scrollRoot) return;
|
||||
|
||||
const padding = 8;
|
||||
const groupHeader = findPreviousGroupHeader(card);
|
||||
const cardTop = card.offsetTop;
|
||||
const cardBottom = cardTop + card.offsetHeight;
|
||||
const contextTop = groupHeader?.offsetTop ?? cardTop;
|
||||
const visibleTop = scrollRoot.scrollTop + padding;
|
||||
const visibleBottom = scrollRoot.scrollTop + scrollRoot.clientHeight - padding;
|
||||
|
||||
if (contextTop < visibleTop) {
|
||||
scrollRoot.scrollTop = Math.max(0, contextTop - padding);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardBottom > visibleBottom) {
|
||||
scrollRoot.scrollTop = cardBottom - scrollRoot.clientHeight + padding;
|
||||
}
|
||||
}
|
||||
|
||||
function findPreviousGroupHeader(card: HTMLElement): HTMLElement | null {
|
||||
let current = card.previousElementSibling;
|
||||
while (current) {
|
||||
if (current instanceof HTMLElement && current.dataset.hoverPopupGroupHeader === "true") {
|
||||
return current;
|
||||
}
|
||||
current = current.previousElementSibling;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stylePopupChrome(popup: maplibregl.Popup) {
|
||||
const element = popup.getElement();
|
||||
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
|
||||
|
||||
@@ -93,7 +93,7 @@ export function useMapInstance() {
|
||||
}
|
||||
};
|
||||
|
||||
let throttleTimeout: any = null;
|
||||
let throttleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const syncZoomLevelImmediate = () => {
|
||||
if (isZoomSliderDraggingRef.current) return;
|
||||
|
||||
@@ -66,10 +66,15 @@ export function useMapInteraction({
|
||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||
|
||||
const allowGeometryEditingRef = useRef(allowGeometryEditing);
|
||||
allowGeometryEditingRef.current = allowGeometryEditing;
|
||||
|
||||
const allowFeatureSelectionRef = useRef(allowFeatureSelection);
|
||||
allowFeatureSelectionRef.current = allowFeatureSelection;
|
||||
|
||||
useEffect(() => {
|
||||
allowGeometryEditingRef.current = allowGeometryEditing;
|
||||
}, [allowGeometryEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
allowFeatureSelectionRef.current = allowFeatureSelection;
|
||||
}, [allowFeatureSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingEngineRef.current) {
|
||||
@@ -132,7 +137,7 @@ export function useMapInteraction({
|
||||
}, [mode, mapRef]);
|
||||
|
||||
const setupMapInteractions = (map: maplibregl.Map) => {
|
||||
(map as any)._renderDraftRef = renderDraftRef;
|
||||
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
|
||||
const drawingEngine = initDrawing(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
@@ -355,6 +360,10 @@ export function useMapInteraction({
|
||||
};
|
||||
}
|
||||
|
||||
type MapWithRenderDraftRef = maplibregl.Map & {
|
||||
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
|
||||
};
|
||||
|
||||
function buildDuplicatedFeatureShapeOnly(
|
||||
feature: FeatureCollection["features"][number]
|
||||
): FeatureCollection["features"][number] {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ensurePathArrowIcon,
|
||||
} from "./mapUtils";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
|
||||
export function getBaseMapStyle(): maplibregl.StyleSpecification {
|
||||
return {
|
||||
@@ -224,6 +223,7 @@ export function setupMapLayers(
|
||||
"delete", "#ef4444",
|
||||
"vertex", "#22c55e",
|
||||
"edge", "#eab308",
|
||||
"unknown", "#22c55e",
|
||||
"#3b82f6" // default none
|
||||
],
|
||||
"circle-radius": 22,
|
||||
@@ -243,6 +243,7 @@ export function setupMapLayers(
|
||||
"delete", "#ef4444",
|
||||
"vertex", "#22c55e",
|
||||
"edge", "#eab308",
|
||||
"unknown", "#22c55e",
|
||||
"#3b82f6" // default none
|
||||
],
|
||||
"circle-radius": 12,
|
||||
@@ -252,6 +253,7 @@ export function setupMapLayers(
|
||||
"delete", "#7f1d1d",
|
||||
"vertex", "#14532d",
|
||||
"edge", "#713f12",
|
||||
"unknown", "#14532d",
|
||||
"#0f172a" // default none
|
||||
],
|
||||
"circle-stroke-width": 3,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
import {
|
||||
applyBackgroundLayerVisibility,
|
||||
@@ -80,18 +79,53 @@ export function useMapSync({
|
||||
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
|
||||
const isPreviewModeRef = useRef(isPreviewMode);
|
||||
|
||||
renderDraftRef.current = renderDraft;
|
||||
labelContextDraftRef.current = labelContextDraft;
|
||||
labelTimelineYearRef.current = labelTimelineYear;
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
geometryVisibilityRef.current = geometryVisibility;
|
||||
selectedFeatureIdsRef.current = selectedFeatureIds;
|
||||
applyGeometryBindingFilterRef.current = applyGeometryBindingFilter;
|
||||
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||
imageOverlayRef.current = imageOverlay || null;
|
||||
focusFeatureCollectionRef.current = focusFeatureCollection;
|
||||
focusPaddingRef.current = focusPadding;
|
||||
isPreviewModeRef.current = isPreviewMode;
|
||||
useEffect(() => {
|
||||
renderDraftRef.current = renderDraft;
|
||||
}, [renderDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
labelContextDraftRef.current = labelContextDraft;
|
||||
}, [labelContextDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
labelTimelineYearRef.current = labelTimelineYear;
|
||||
}, [labelTimelineYear]);
|
||||
|
||||
useEffect(() => {
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
}, [backgroundVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
geometryVisibilityRef.current = geometryVisibility;
|
||||
}, [geometryVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedFeatureIdsRef.current = selectedFeatureIds;
|
||||
}, [selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
applyGeometryBindingFilterRef.current = applyGeometryBindingFilter;
|
||||
}, [applyGeometryBindingFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||
}, [fitToDraftBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
imageOverlayRef.current = imageOverlay || null;
|
||||
}, [imageOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
focusFeatureCollectionRef.current = focusFeatureCollection;
|
||||
}, [focusFeatureCollection]);
|
||||
|
||||
useEffect(() => {
|
||||
focusPaddingRef.current = focusPadding;
|
||||
}, [focusPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
isPreviewModeRef.current = isPreviewMode;
|
||||
}, [isPreviewMode]);
|
||||
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
const lastCountriesStrRef = useRef("");
|
||||
@@ -102,7 +136,7 @@ export function useMapSync({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
(map as any)._renderDraftRef = renderDraftRef;
|
||||
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
|
||||
}
|
||||
}, [mapRef]);
|
||||
|
||||
@@ -299,3 +333,7 @@ export function useMapSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type MapWithRenderDraftRef = maplibregl.Map & {
|
||||
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,9 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { type BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
|
||||
import { isTimelineYearWithinEntityTimeRange } from "@/uhm/lib/utils/entityTime";
|
||||
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { ReplayPreviewController } from "@/uhm/lib/replay/useReplayPreview";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -47,7 +49,7 @@ type Props = {
|
||||
selectedStepIndex?: number | null;
|
||||
autoplayMode?: "start" | "selection" | null;
|
||||
|
||||
replayPreview: any;
|
||||
replayPreview: ReplayPreviewController;
|
||||
mapHandleRef?: RefObject<MapHandle | null>;
|
||||
previewRelations: PreviewRelationIndex;
|
||||
previewActiveEntityId: string | null;
|
||||
@@ -116,13 +118,16 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
|
||||
// Clear preview states when currentActiveReplay or mode changes
|
||||
useEffect(() => {
|
||||
setPreviewWikiCache({});
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewActiveEntityId(null);
|
||||
setIsPreviewEntitySidebarOpen(false);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPreviewWikiCache({});
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewActiveEntityId(null);
|
||||
setIsPreviewEntitySidebarOpen(false);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [currentActiveReplay, mode, setPreviewActiveEntityId, setPreviewWikiCache]);
|
||||
|
||||
const autoplayedReplayIdRef = useRef<string | number | null>(null);
|
||||
@@ -180,40 +185,73 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
// Load active wiki content if needed
|
||||
useEffect(() => {
|
||||
if (!mode || !replayPreviewSidebarOpen) {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const setWikiIdle = (error: string | null = null) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPreviewWikiError(error);
|
||||
setIsPreviewWikiLoading(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
const setWikiLoading = () => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
let pendingStateCleanup: (() => void) | null = null;
|
||||
const cleanupPendingState = () => {
|
||||
pendingStateCleanup?.();
|
||||
pendingStateCleanup = null;
|
||||
};
|
||||
|
||||
const scheduleWikiIdle = (error: string | null = null) => {
|
||||
cleanupPendingState();
|
||||
pendingStateCleanup = setWikiIdle(error);
|
||||
};
|
||||
|
||||
const scheduleWikiLoading = () => {
|
||||
cleanupPendingState();
|
||||
pendingStateCleanup = setWikiLoading();
|
||||
};
|
||||
|
||||
const cleanupEffect = () => {
|
||||
cleanupPendingState();
|
||||
};
|
||||
|
||||
const activeWikiId = String(replayPreviewActiveWikiId || "").trim();
|
||||
if (!activeWikiId.length) {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
return;
|
||||
scheduleWikiIdle();
|
||||
return cleanupEffect;
|
||||
}
|
||||
|
||||
const localWiki = wikis.find((item) => item.id === activeWikiId) || null;
|
||||
if (!localWiki) {
|
||||
setPreviewWikiError("Không tìm thấy wiki trong snapshot preview.");
|
||||
setIsPreviewWikiLoading(false);
|
||||
return;
|
||||
scheduleWikiIdle("Không tìm thấy wiki trong snapshot preview.");
|
||||
return cleanupEffect;
|
||||
}
|
||||
|
||||
if (typeof localWiki.doc === "string") {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
return;
|
||||
scheduleWikiIdle();
|
||||
return cleanupEffect;
|
||||
}
|
||||
|
||||
if (previewWikiCache[activeWikiId]) {
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(false);
|
||||
return;
|
||||
scheduleWikiIdle();
|
||||
return cleanupEffect;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setPreviewWikiError(null);
|
||||
setIsPreviewWikiLoading(true);
|
||||
scheduleWikiLoading();
|
||||
void fetchWikiById(activeWikiId)
|
||||
.then((row) => {
|
||||
if (disposed) return;
|
||||
@@ -231,12 +269,14 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanupEffect();
|
||||
};
|
||||
}, [
|
||||
mode,
|
||||
previewWikiCache,
|
||||
replayPreviewActiveWikiId,
|
||||
replayPreviewSidebarOpen,
|
||||
setPreviewWikiCache,
|
||||
wikis,
|
||||
]);
|
||||
|
||||
@@ -401,25 +441,67 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
// Hover popup content provider
|
||||
const getPreviewHoverPopupContent = useCallback((feature: Feature) => {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
const sourceFeatureId = (feature as { id?: string | number }).id ?? null;
|
||||
const entitiesForFeature = entityIds
|
||||
.map((entityId) => previewRelations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
if (!entitiesForFeature.length) return null;
|
||||
|
||||
return {
|
||||
rows: entitiesForFeature.flatMap((entity) => {
|
||||
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
||||
if (!linkedWikis.length) {
|
||||
return [{ title: entity.name || String(entity.id), quote: "" }];
|
||||
}
|
||||
type GroupedHoverRow = MapHoverPopupContent["rows"][number] & { isTimelineMatch: boolean };
|
||||
const groupedRows: GroupedHoverRow[] = entitiesForFeature.flatMap((entity): GroupedHoverRow[] => {
|
||||
const title = entity.name || String(entity.id);
|
||||
const isTimelineMatch = isTimelineYearWithinEntityTimeRange(
|
||||
activeTimelineYear,
|
||||
entity.time_start,
|
||||
entity.time_end
|
||||
);
|
||||
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
||||
const entityHeaderRow = {
|
||||
title,
|
||||
description: entity.description,
|
||||
isGroupHeader: true,
|
||||
isTimelineMatch,
|
||||
};
|
||||
|
||||
return linkedWikis.map((wiki) => ({
|
||||
title: entity.name || String(entity.id),
|
||||
if (!linkedWikis.length) {
|
||||
return [entityHeaderRow, {
|
||||
title: "(chưa có wiki)",
|
||||
titleTone: "danger",
|
||||
isTimelineMatch,
|
||||
quoteTone: "danger",
|
||||
}];
|
||||
}
|
||||
|
||||
return [
|
||||
entityHeaderRow,
|
||||
...linkedWikis.map((wiki) => ({
|
||||
title: getWikiHoverTitle(wiki, title),
|
||||
isTimelineMatch,
|
||||
quote: extractWikiBlockquoteText(wiki.content),
|
||||
}));
|
||||
}),
|
||||
onClick: () => selectReplayPreviewEntity(entity.id, {
|
||||
sourceFeatureId,
|
||||
preferredWikiId: wiki.id,
|
||||
focusMap: false,
|
||||
selectGeometry: false,
|
||||
}),
|
||||
})),
|
||||
];
|
||||
});
|
||||
const timelineMatchedRows = groupedRows.filter((row) => row.isTimelineMatch);
|
||||
const otherRows = groupedRows.filter((row) => !row.isTimelineMatch);
|
||||
const stripGroupFlag = ({ isTimelineMatch: _isTimelineMatch, ...row }: GroupedHoverRow) => row;
|
||||
|
||||
return {
|
||||
key: `${activeTimelineYear}:${groupedRows.map((row) => `${row.title}:${row.description || ""}:${row.isTimelineMatch ? "in" : "out"}`).join("|")}`,
|
||||
rows: [
|
||||
...timelineMatchedRows.map(stripGroupFlag),
|
||||
...otherRows.map((row, index) => ({
|
||||
...stripGroupFlag(row),
|
||||
separatorBefore: index === 0 && timelineMatchedRows.length > 0,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}, [previewRelations.entitiesById, previewRelations.entityWikisById]);
|
||||
}, [activeTimelineYear, previewRelations.entitiesById, previewRelations.entityWikisById, selectReplayPreviewEntity]);
|
||||
|
||||
// Wiki inner links click handler
|
||||
const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||
@@ -514,6 +596,33 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
}
|
||||
}, [mapHandleRef, previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]);
|
||||
|
||||
const handleFocusWiki = useCallback((wiki: Wiki) => {
|
||||
setFocusedPresentPlace(null);
|
||||
|
||||
const entityIds = previewRelations.wikiEntityIdsById[wiki.id] || [];
|
||||
if (entityIds.length > 0) {
|
||||
selectReplayPreviewEntity(entityIds[0], {
|
||||
preferredWikiId: wiki.id,
|
||||
focusMap: true,
|
||||
});
|
||||
|
||||
// Focus geometries if any
|
||||
const geometries = previewRelations.entityGeometriesById[entityIds[0]];
|
||||
const map = mapHandleRef?.current?.getMap();
|
||||
if (map && geometries && geometries.features.length > 0) {
|
||||
fitMapToFeatureCollection(map, geometries, 84, { duration: 1000 });
|
||||
}
|
||||
} else {
|
||||
// Direct wiki select without entity
|
||||
setPreviewActiveEntityId(null);
|
||||
setIsPreviewEntitySidebarOpen(true);
|
||||
setPreviewWikiError(null);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
openReplayPreviewWikiPanelById(wiki.id);
|
||||
}
|
||||
}, [previewRelations, selectReplayPreviewEntity, setPreviewActiveEntityId, openReplayPreviewWikiPanelById, mapHandleRef]);
|
||||
|
||||
const effectiveGeometryVisibility = useMemo(() => {
|
||||
return geometryVisibility;
|
||||
}, [geometryVisibility]);
|
||||
@@ -566,6 +675,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
focusedPlace={focusedPresentPlace}
|
||||
onFocusPlace={handleFocusPresentPlace}
|
||||
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
|
||||
onFocusWiki={handleFocusWiki}
|
||||
onClearFocus={clearPresentPlaceFocus}
|
||||
/>
|
||||
|
||||
@@ -596,6 +706,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
left: isLargeScreen ? "auto" : 16,
|
||||
width: isLargeScreen ? `min(${previewSidebarWidth}px, calc(100vw - 2rem))` : "auto",
|
||||
maxWidth: "calc(100vw - 2rem)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
@@ -725,6 +836,8 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
);
|
||||
});
|
||||
|
||||
PreviewLayout.displayName = "PreviewLayout";
|
||||
|
||||
export default PreviewLayout;
|
||||
|
||||
// ==========================================
|
||||
@@ -751,6 +864,10 @@ function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getWikiHoverTitle(wiki: { title?: string | null } | null | undefined, fallbackTitle: string): string {
|
||||
return String(wiki?.title || "").trim() || fallbackTitle;
|
||||
}
|
||||
|
||||
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
|
||||
const margin = 12;
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||
|
||||
@@ -38,6 +38,7 @@ type Props = {
|
||||
onFeatureClick?: (payload: MapFeaturePayload | null) => void;
|
||||
hoverPopupEnabled?: boolean;
|
||||
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
|
||||
onHoverFeatureChange?: (feature: Feature | null) => void;
|
||||
activeEntity?: Entity | null;
|
||||
activeWiki?: Wiki | null;
|
||||
isWikiLoading?: boolean;
|
||||
@@ -83,6 +84,7 @@ export default function PreviewMapShell({
|
||||
onFeatureClick,
|
||||
hoverPopupEnabled = false,
|
||||
getHoverPopupContent,
|
||||
onHoverFeatureChange,
|
||||
activeEntity = null,
|
||||
activeWiki = null,
|
||||
isWikiLoading = false,
|
||||
@@ -121,16 +123,11 @@ export default function PreviewMapShell({
|
||||
useEffect(() => {
|
||||
const fetchUserAvatar = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser({ skipRefresh: true } as any);
|
||||
console.log("PreviewMapShell: Fetched userData:", userData);
|
||||
if (userData?.data?.profile?.avatar_url) {
|
||||
console.log("PreviewMapShell: Setting avatarUrl to:", userData.data.profile.avatar_url);
|
||||
setAvatarUrl(userData.data.profile.avatar_url);
|
||||
} else {
|
||||
console.log("PreviewMapShell: No avatar_url in profile:", userData?.data?.profile);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("PreviewMapShell: No active session (user is guest)");
|
||||
const userData = await apiGetCurrentUser({ skipRefresh: true });
|
||||
const nextAvatarUrl = getCurrentUserAvatarUrl(userData);
|
||||
if (nextAvatarUrl) setAvatarUrl(nextAvatarUrl);
|
||||
} catch {
|
||||
// Guest preview does not need an authenticated profile.
|
||||
}
|
||||
};
|
||||
fetchUserAvatar();
|
||||
@@ -172,9 +169,10 @@ export default function PreviewMapShell({
|
||||
onFeatureClick={onFeatureClick}
|
||||
hoverPopupEnabled={hoverPopupEnabled}
|
||||
getHoverPopupContent={getHoverPopupContent}
|
||||
onHoverFeatureChange={onHoverFeatureChange}
|
||||
onPlayPreviewReplay={onPlayPreviewReplay}
|
||||
onLoad={onLoad}
|
||||
showViewportControls={false}
|
||||
showViewportControls={!isMobileOrTablet}
|
||||
height="100svh"
|
||||
/>
|
||||
|
||||
@@ -209,7 +207,7 @@ export default function PreviewMapShell({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
bottom: (activeEntity && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||
bottom: ((activeEntity || activeWiki) && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||
left: 18,
|
||||
zIndex: 18,
|
||||
display: "flex",
|
||||
@@ -414,9 +412,9 @@ export default function PreviewMapShell({
|
||||
|
||||
{overlay}
|
||||
|
||||
{activeEntity ? (
|
||||
{activeEntity || activeWiki ? (
|
||||
<aside
|
||||
className={isMobileOrTablet ? "" : "absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
|
||||
className={isMobileOrTablet ? "uhm-public-wiki-sidebar" : "uhm-public-wiki-sidebar absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
|
||||
style={isMobileOrTablet ? {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
@@ -429,7 +427,9 @@ export default function PreviewMapShell({
|
||||
maxWidth: "100%",
|
||||
zIndex: 20,
|
||||
// Do not transition height during drag resizing for butter smoothness
|
||||
} : undefined}
|
||||
} : {
|
||||
width: `min(${sidebarWidth || 420}px, calc(100vw - 2rem))`,
|
||||
}}
|
||||
>
|
||||
<PublicWikiSidebar
|
||||
entity={activeEntity}
|
||||
@@ -454,3 +454,13 @@ export default function PreviewMapShell({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentUserAvatarUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const data = (value as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const profile = (data as { profile?: unknown }).profile;
|
||||
if (!profile || typeof profile !== "object") return null;
|
||||
const avatarUrl = (profile as { avatar_url?: unknown }).avatar_url;
|
||||
return typeof avatarUrl === "string" && avatarUrl.trim() ? avatarUrl : null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import PresentPlaceSearch, {
|
||||
type HistoricalGeometryFocusPayload,
|
||||
type PresentPlaceSelection,
|
||||
} from "@/uhm/components/editor/PresentPlaceSearch";
|
||||
import { type Wiki } from "@/uhm/api/wikis";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import {
|
||||
type BackgroundLayerId,
|
||||
@@ -143,6 +144,7 @@ export default function PublicPreviewClientPage({
|
||||
isRelationsLoading,
|
||||
relationsStatus,
|
||||
replays,
|
||||
ensureChildrenForGeometry,
|
||||
} = usePublicPreviewData({ timelineYear: searchTimelineYear, timeRange, enabled: loadInteractiveMap });
|
||||
|
||||
const activeReplay = useMemo(() => {
|
||||
@@ -200,6 +202,7 @@ export default function PublicPreviewClientPage({
|
||||
linkEntityPopupRef,
|
||||
getHoverPopupContent,
|
||||
selectEntity,
|
||||
selectWiki,
|
||||
handleWikiLinkRequest,
|
||||
closeWikiSidebar,
|
||||
setLinkEntityPopup,
|
||||
@@ -210,10 +213,18 @@ export default function PublicPreviewClientPage({
|
||||
setRelations,
|
||||
selectedFeatureIds,
|
||||
setSelectedFeatureIds,
|
||||
timelineYear: replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear,
|
||||
replayActiveWikiId: replayPreview.activeWikiId,
|
||||
replayMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds.length) return;
|
||||
for (const featureId of selectedFeatureIds) {
|
||||
void ensureChildrenForGeometry(featureId);
|
||||
}
|
||||
}, [ensureChildrenForGeometry, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handleResize = () => {
|
||||
@@ -362,6 +373,23 @@ export default function PublicPreviewClientPage({
|
||||
}
|
||||
}, [relations.geometryEntityIds, selectEntity, setSelectedFeatureIds]);
|
||||
|
||||
const handleFocusWiki = useCallback((wiki: Wiki) => {
|
||||
setFocusedPresentPlace(null);
|
||||
selectWiki(wiki);
|
||||
|
||||
// Focus geometries if any
|
||||
const entityIds = relations.wikiEntityIdsById[wiki.id] || [];
|
||||
if (entityIds.length > 0) {
|
||||
const geometries = relations.entityGeometriesById[entityIds[0]];
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (map && geometries && geometries.features.length > 0) {
|
||||
import("@/uhm/components/map/mapUtils").then(({ fitMapToFeatureCollection }) => {
|
||||
fitMapToFeatureCollection(map, geometries, 84, { duration: 1000 });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [relations.wikiEntityIdsById, relations.entityGeometriesById, selectWiki]);
|
||||
|
||||
const filteredRenderDraft = useMemo(() => {
|
||||
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
|
||||
return renderDraft;
|
||||
@@ -402,22 +430,22 @@ export default function PublicPreviewClientPage({
|
||||
|
||||
const isSidebarOpen = replayMode === "playing"
|
||||
? (replayPreview.sidebarOpen || isManualSidebarOpen)
|
||||
: Boolean(activeEntity);
|
||||
: Boolean(activeEntity || activeWiki || isManualSidebarOpen);
|
||||
|
||||
const displayedActiveEntity = isSidebarOpen ? activeEntity : null;
|
||||
const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
|
||||
|
||||
const computedTimelineStyle = useMemo(() => {
|
||||
const leftMargin = isLayerPanelVisible ? 88 : 18;
|
||||
const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18;
|
||||
const bottomOffset = (displayedActiveEntity && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
|
||||
const rightMargin = ((displayedActiveEntity || displayedActiveWiki) && isLargeScreen) ? sidebarWidth + 32 : 18;
|
||||
const bottomOffset = ((displayedActiveEntity || displayedActiveWiki) && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
|
||||
return {
|
||||
left: `${leftMargin}px`,
|
||||
right: `${rightMargin}px`,
|
||||
bottom: bottomOffset,
|
||||
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1), bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
};
|
||||
}, [isLayerPanelVisible, displayedActiveEntity, isLargeScreen, sidebarWidth, sidebarHeight]);
|
||||
}, [isLayerPanelVisible, displayedActiveEntity, displayedActiveWiki, isLargeScreen, sidebarWidth, sidebarHeight]);
|
||||
|
||||
const searchBarWidth = useMemo(() => {
|
||||
if (isLargeScreen) {
|
||||
@@ -431,9 +459,9 @@ export default function PublicPreviewClientPage({
|
||||
return {
|
||||
position: "absolute" as const,
|
||||
top: 10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
left: 84,
|
||||
right: "auto",
|
||||
transform: "none",
|
||||
zIndex: 18,
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
@@ -526,6 +554,7 @@ export default function PublicPreviewClientPage({
|
||||
focusedPlace={focusedPresentPlace}
|
||||
onFocusPlace={handleFocusPresentPlace}
|
||||
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
|
||||
onFocusWiki={handleFocusWiki}
|
||||
onClearFocus={clearPresentPlaceFocus}
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -590,4 +619,3 @@ export default function PublicPreviewClientPage({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
@@ -9,31 +9,12 @@ import MapPlaceholder from "./MapPlaceholder";
|
||||
|
||||
// Loader component to handle dynamic chunk loading with Redux support
|
||||
const LoaderComponent = () => {
|
||||
const mapEntered = useSelector((state: RootState) => state.ui.mapEntered);
|
||||
const dispatch = useDispatch();
|
||||
const [instantLoad, setInstantLoad] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("instant-load");
|
||||
if (saved !== null) {
|
||||
setInstantLoad(saved === "true");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEnter = () => {
|
||||
dispatch(setMapEntered(true));
|
||||
};
|
||||
|
||||
const toggleInstantLoad = (val: boolean) => {
|
||||
setInstantLoad(val);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("instant-load", String(val));
|
||||
}
|
||||
window.dispatchEvent(new Event("instant-load-changed"));
|
||||
};
|
||||
|
||||
return (
|
||||
<MapPlaceholder
|
||||
onEnter={handleEnter}
|
||||
@@ -52,37 +33,28 @@ const PublicPreviewClientPage = dynamic(
|
||||
export default function PublicPreviewWrapper() {
|
||||
const mapEntered = useSelector((state: RootState) => state.ui.mapEntered);
|
||||
const dispatch = useDispatch();
|
||||
const [instantLoad, setInstantLoad] = useState<boolean>(true);
|
||||
const [instantLoad, setInstantLoad] = useState<boolean>(() => readInstantLoadPreference());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("instant-load");
|
||||
if (saved !== null) {
|
||||
setInstantLoad(saved === "true");
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
const updated = localStorage.getItem("instant-load");
|
||||
if (updated !== null) {
|
||||
setInstantLoad(updated === "true");
|
||||
}
|
||||
};
|
||||
window.addEventListener("instant-load-changed", handleSync);
|
||||
return () => window.removeEventListener("instant-load-changed", handleSync);
|
||||
}
|
||||
if (typeof window === "undefined") return;
|
||||
const handleSync = () => {
|
||||
setInstantLoad(readInstantLoadPreference());
|
||||
};
|
||||
window.addEventListener("instant-load-changed", handleSync);
|
||||
return () => window.removeEventListener("instant-load-changed", handleSync);
|
||||
}, []);
|
||||
|
||||
const toggleInstantLoad = (val: boolean) => {
|
||||
const toggleInstantLoad = useCallback((val: boolean) => {
|
||||
setInstantLoad(val);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("instant-load", String(val));
|
||||
window.dispatchEvent(new Event("instant-load-changed"));
|
||||
}
|
||||
window.dispatchEvent(new Event("instant-load-changed"));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEnter = () => {
|
||||
const handleEnter = useCallback(() => {
|
||||
dispatch(setMapEntered(true));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapEntered) return;
|
||||
@@ -114,7 +86,7 @@ export default function PublicPreviewWrapper() {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
};
|
||||
}, [mapEntered]);
|
||||
}, [handleEnter, mapEntered]);
|
||||
|
||||
if (!mapEntered && !instantLoad) {
|
||||
return (
|
||||
@@ -133,3 +105,13 @@ export default function PublicPreviewWrapper() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function readInstantLoadPreference(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const saved = window.localStorage.getItem("instant-load");
|
||||
return saved === null ? true : saved === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||
import { fetchGeometriesByBBox, fetchGeometriesByBoundWith } from "@/uhm/api/geometries";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import {
|
||||
fetchEntitiesByGeometryIds,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type PreviewRelationIndex,
|
||||
} from "@/uhm/lib/preview/types";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import type { FeatureCollection, FeatureEntityPreview, FeatureWikiPreview } from "@/uhm/types/geo";
|
||||
import type { BattleReplay } from "@/uhm/types/projects";
|
||||
import { fetchBattleReplaysByGeometryIds } from "@/uhm/api/battleReplays";
|
||||
|
||||
@@ -37,6 +37,7 @@ export function usePublicPreviewData(options: {
|
||||
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
|
||||
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const loadedChildGeometryParentIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -47,6 +48,7 @@ export function usePublicPreviewData(options: {
|
||||
setIsRelationsLoading(false);
|
||||
setTimelineStatus(null);
|
||||
setRelationsStatus(null);
|
||||
loadedChildGeometryParentIdsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,10 +60,11 @@ export function usePublicPreviewData(options: {
|
||||
setIsRelationsLoading(false);
|
||||
setTimelineStatus(null);
|
||||
setRelationsStatus(null);
|
||||
loadedChildGeometryParentIdsRef.current.clear();
|
||||
let next: FeatureCollection;
|
||||
|
||||
try {
|
||||
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
||||
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange, hasBound: false });
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -99,14 +102,17 @@ export function usePublicPreviewData(options: {
|
||||
setRelationsStatus("Đang nạp liên kết entity/wiki.");
|
||||
let entitiesByGeometryId: Record<string, Entity[]>;
|
||||
let fetchedReplays: BattleReplay[] = [];
|
||||
const geometryIdsWithReplays = getGeometryIdsWithReplays(next);
|
||||
|
||||
try {
|
||||
const [entities, replaysRes] = await Promise.all([
|
||||
fetchEntitiesByGeometryIds(geometryIds),
|
||||
fetchBattleReplaysByGeometryIds(geometryIds).catch((err) => {
|
||||
console.error("Failed to load replays:", err);
|
||||
return {};
|
||||
}),
|
||||
geometryIdsWithReplays.length
|
||||
? fetchBattleReplaysByGeometryIds(geometryIdsWithReplays).catch((err) => {
|
||||
console.error("Failed to load replays:", err);
|
||||
return {};
|
||||
})
|
||||
: Promise.resolve({}),
|
||||
]);
|
||||
entitiesByGeometryId = entities;
|
||||
|
||||
@@ -142,8 +148,22 @@ export function usePublicPreviewData(options: {
|
||||
.flat()
|
||||
.map((entity) => entity.id)
|
||||
);
|
||||
let wikisByEntityId: Record<string, Wiki[]> = {};
|
||||
if (entityIds.length) {
|
||||
try {
|
||||
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load initial entity-wiki previews failed", err.body);
|
||||
} else {
|
||||
console.error("Load initial entity-wiki previews failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entityOnlyRelations = buildPublicPreviewRelationIndex(
|
||||
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {})
|
||||
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
|
||||
);
|
||||
|
||||
// Apply BOTH geometries and relations at the exact same React render cycle!
|
||||
@@ -154,26 +174,6 @@ export function usePublicPreviewData(options: {
|
||||
// Mark loading as complete immediately so map transitions and becomes interactive
|
||||
setIsRelationsLoading(false);
|
||||
setRelationsStatus(null);
|
||||
|
||||
if (!entityIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch wiki previews in the background to populate hover cards without blocking map init
|
||||
try {
|
||||
const wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
|
||||
setRelations(buildPublicPreviewRelationIndex(
|
||||
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
|
||||
));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load background entity-wiki previews failed", err.body);
|
||||
} else {
|
||||
console.error("Load background entity-wiki previews failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadByTimeline();
|
||||
@@ -187,6 +187,59 @@ export function usePublicPreviewData(options: {
|
||||
[data, relations]
|
||||
);
|
||||
|
||||
const ensureChildrenForGeometry = useCallback(async (parentGeometryId: string | number | null | undefined) => {
|
||||
const parentId = String(parentGeometryId || "").trim();
|
||||
if (!parentId || loadedChildGeometryParentIdsRef.current.has(parentId)) return;
|
||||
loadedChildGeometryParentIdsRef.current.add(parentId);
|
||||
|
||||
let childFc: FeatureCollection;
|
||||
try {
|
||||
childFc = await fetchGeometriesByBoundWith(parentId);
|
||||
} catch (err) {
|
||||
loadedChildGeometryParentIdsRef.current.delete(parentId);
|
||||
console.error("Load child geometries failed", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const childGeometryIds = uniqueStrings(childFc.features.map((feature) => String(feature.properties.id || "")));
|
||||
if (!childGeometryIds.length) return;
|
||||
const childGeometryIdsWithReplays = getGeometryIdsWithReplays(childFc);
|
||||
|
||||
setData((prev) => mergeFeatureCollections(prev, childFc));
|
||||
|
||||
let entitiesByGeometryId: Record<string, Entity[]> = {};
|
||||
let wikisByEntityId: Record<string, Wiki[]> = {};
|
||||
try {
|
||||
entitiesByGeometryId = await fetchEntitiesByGeometryIds(childGeometryIds);
|
||||
const entityIds = uniqueStrings(
|
||||
Object.values(entitiesByGeometryId)
|
||||
.flat()
|
||||
.map((entity) => entity.id)
|
||||
);
|
||||
if (entityIds.length) {
|
||||
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Load child geometry relations failed", err);
|
||||
}
|
||||
|
||||
const childRelations = buildPublicPreviewRelationIndex(
|
||||
buildRelationInputFromGeometryRelations(childFc, entitiesByGeometryId, wikisByEntityId)
|
||||
);
|
||||
setRelations((prev) => mergePreviewRelationIndexes(prev, childRelations));
|
||||
|
||||
try {
|
||||
if (!childGeometryIdsWithReplays.length) return;
|
||||
const replayRows = await fetchBattleReplaysByGeometryIds(childGeometryIdsWithReplays);
|
||||
const childReplays = Object.values(replayRows).flat();
|
||||
if (childReplays.length) {
|
||||
setReplays((prev) => mergeReplays(prev, childReplays));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Load child geometry replays failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
renderDraft: labelContextDraft,
|
||||
@@ -198,6 +251,7 @@ export function usePublicPreviewData(options: {
|
||||
isRelationsLoading,
|
||||
relationsStatus,
|
||||
replays,
|
||||
ensureChildrenForGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,24 +266,89 @@ function buildRelationInputFromGeometryRelations(
|
||||
} {
|
||||
const entitiesById: Record<string, Entity> = {};
|
||||
const entityGeometriesById: Record<string, FeatureCollection> = {};
|
||||
const mergedWikisByEntityId: Record<string, Wiki[]> = {};
|
||||
|
||||
for (const feature of draft.features) {
|
||||
const geometryId = String(feature.properties.id);
|
||||
const embeddedEntities = Array.isArray(feature.properties.public_entity_previews)
|
||||
? feature.properties.public_entity_previews
|
||||
: [];
|
||||
for (const entity of entitiesByGeometryId[geometryId] || []) {
|
||||
const id = String(entity?.id || "").trim();
|
||||
if (!id) continue;
|
||||
entitiesById[id] = entity;
|
||||
pushFeature(entityGeometriesById, id, feature);
|
||||
}
|
||||
for (const entityPreview of embeddedEntities) {
|
||||
const entity = featureEntityPreviewToEntity(entityPreview);
|
||||
if (!entity) continue;
|
||||
entitiesById[entity.id] = {
|
||||
...entity,
|
||||
...entitiesById[entity.id],
|
||||
};
|
||||
pushFeature(entityGeometriesById, entity.id, feature);
|
||||
pushWikis(
|
||||
mergedWikisByEntityId,
|
||||
entity.id,
|
||||
(entityPreview.wikis || []).map(featureWikiPreviewToWiki).filter((wiki): wiki is Wiki => Boolean(wiki))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) {
|
||||
pushWikis(mergedWikisByEntityId, entityId, wikis || []);
|
||||
}
|
||||
|
||||
return {
|
||||
entities: Object.values(entitiesById),
|
||||
entityGeometriesById,
|
||||
entityWikisById: wikisByEntityId,
|
||||
entityWikisById: mergedWikisByEntityId,
|
||||
};
|
||||
}
|
||||
|
||||
function featureEntityPreviewToEntity(preview: FeatureEntityPreview): Entity | null {
|
||||
const id = String(preview?.id || "").trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: String(preview.name || id).trim() || id,
|
||||
description: preview.description ?? null,
|
||||
time_start: preview.time_start ?? null,
|
||||
time_end: preview.time_end ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function featureWikiPreviewToWiki(preview: FeatureWikiPreview): Wiki | null {
|
||||
const id = String(preview?.id || "").trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
project_id: "",
|
||||
title: preview.title || undefined,
|
||||
slug: preview.slug ?? null,
|
||||
content: preview.content || "",
|
||||
preview_quote: preview.preview_quote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushWikis(target: Record<string, Wiki[]>, entityId: string, wikis: Wiki[]) {
|
||||
const id = String(entityId || "").trim();
|
||||
if (!id) return;
|
||||
if (!target[id]) target[id] = [];
|
||||
for (const wiki of wikis || []) {
|
||||
if (!wiki?.id) continue;
|
||||
const existingIndex = target[id].findIndex((item) => item.id === wiki.id);
|
||||
if (existingIndex >= 0) {
|
||||
target[id][existingIndex] = {
|
||||
...target[id][existingIndex],
|
||||
...wiki,
|
||||
};
|
||||
} else {
|
||||
target[id].push(wiki);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushFeature(target: Record<string, FeatureCollection>, entityId: string, feature: FeatureCollection["features"][number]) {
|
||||
if (!target[entityId]) target[entityId] = { type: "FeatureCollection", features: [] };
|
||||
if (!target[entityId].features.some((item) => String(item.properties.id) === String(feature.properties.id))) {
|
||||
@@ -237,6 +356,102 @@ function pushFeature(target: Record<string, FeatureCollection>, entityId: string
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFeatureCollections(base: FeatureCollection, incoming: FeatureCollection): FeatureCollection {
|
||||
const byId = new Map<string, FeatureCollection["features"][number]>();
|
||||
for (const feature of base.features || []) {
|
||||
byId.set(String(feature.properties.id), feature);
|
||||
}
|
||||
for (const feature of incoming.features || []) {
|
||||
byId.set(String(feature.properties.id), feature);
|
||||
}
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: Array.from(byId.values()),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePreviewRelationIndexes(base: PreviewRelationIndex, incoming: PreviewRelationIndex): PreviewRelationIndex {
|
||||
return {
|
||||
entitiesById: {
|
||||
...base.entitiesById,
|
||||
...incoming.entitiesById,
|
||||
},
|
||||
entityGeometriesById: mergeFeatureCollectionRecords(base.entityGeometriesById, incoming.entityGeometriesById),
|
||||
entityWikisById: mergeWikiRecords(base.entityWikisById, incoming.entityWikisById),
|
||||
geometryEntityIds: mergeStringArrayRecords(base.geometryEntityIds, incoming.geometryEntityIds),
|
||||
wikiEntityIdsById: mergeStringArrayRecords(base.wikiEntityIdsById, incoming.wikiEntityIdsById),
|
||||
wikiEntityIdsBySlug: mergeStringArrayRecords(base.wikiEntityIdsBySlug, incoming.wikiEntityIdsBySlug),
|
||||
wikiById: {
|
||||
...base.wikiById,
|
||||
...incoming.wikiById,
|
||||
},
|
||||
wikiBySlug: {
|
||||
...base.wikiBySlug,
|
||||
...incoming.wikiBySlug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFeatureCollectionRecords(
|
||||
base: Record<string, FeatureCollection>,
|
||||
incoming: Record<string, FeatureCollection>
|
||||
): Record<string, FeatureCollection> {
|
||||
const next = { ...base };
|
||||
for (const [entityId, fc] of Object.entries(incoming || {})) {
|
||||
next[entityId] = next[entityId] ? mergeFeatureCollections(next[entityId], fc) : fc;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeWikiRecords(base: Record<string, Wiki[]>, incoming: Record<string, Wiki[]>): Record<string, Wiki[]> {
|
||||
const next: Record<string, Wiki[]> = { ...base };
|
||||
for (const [entityId, wikis] of Object.entries(incoming || {})) {
|
||||
next[entityId] = mergeWikis(next[entityId] || [], wikis || []);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeWikis(base: Wiki[], incoming: Wiki[]): Wiki[] {
|
||||
const byId = new Map<string, Wiki>();
|
||||
for (const wiki of base || []) {
|
||||
if (wiki?.id) byId.set(wiki.id, wiki);
|
||||
}
|
||||
for (const wiki of incoming || []) {
|
||||
if (wiki?.id) {
|
||||
byId.set(wiki.id, {
|
||||
...byId.get(wiki.id),
|
||||
...wiki,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function mergeStringArrayRecords(base: Record<string, string[]>, incoming: Record<string, string[]>): Record<string, string[]> {
|
||||
const next: Record<string, string[]> = { ...base };
|
||||
for (const [key, values] of Object.entries(incoming || {})) {
|
||||
next[key] = uniqueStrings([...(next[key] || []), ...(values || [])]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeReplays(base: BattleReplay[], incoming: BattleReplay[]): BattleReplay[] {
|
||||
const byId = new Map<string, BattleReplay>();
|
||||
for (const replay of base || []) {
|
||||
if (replay?.id) byId.set(String(replay.id), replay);
|
||||
}
|
||||
for (const replay of incoming || []) {
|
||||
if (replay?.id) byId.set(String(replay.id), replay);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function getGeometryIdsWithReplays(fc: FeatureCollection): string[] {
|
||||
return uniqueStrings((fc.features || [])
|
||||
.filter((feature) => Array.isArray(feature.properties.replay_ids) && feature.properties.replay_ids.length > 0)
|
||||
.map((feature) => String(feature.properties.id || "")));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(
|
||||
values
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/uhm/api/wikis";
|
||||
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
|
||||
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
|
||||
import { isTimelineYearWithinEntityTimeRange } from "@/uhm/lib/utils/entityTime";
|
||||
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
||||
import type { BattleReplay } from "@/uhm/types/projects";
|
||||
|
||||
@@ -36,10 +37,11 @@ export function usePublicPreviewInteraction(options: {
|
||||
setRelations: React.Dispatch<React.SetStateAction<PreviewRelationIndex>>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
|
||||
timelineYear?: number | null;
|
||||
replayActiveWikiId?: string | null;
|
||||
replayMode?: "idle" | "playing";
|
||||
}) {
|
||||
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, replayActiveWikiId, replayMode } = options;
|
||||
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode } = options;
|
||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
|
||||
@@ -53,7 +55,6 @@ export function usePublicPreviewInteraction(options: {
|
||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadedWikiEntityIdsRef = useRef<Set<string>>(new Set());
|
||||
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +87,7 @@ export function usePublicPreviewInteraction(options: {
|
||||
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||
|
||||
const selectEntity = useCallback((
|
||||
const selectEntity = useCallback(async (
|
||||
entityId: string,
|
||||
selectOptions?: {
|
||||
sourceFeatureId?: string | number | null;
|
||||
@@ -94,11 +95,77 @@ export function usePublicPreviewInteraction(options: {
|
||||
selectGeometry?: boolean;
|
||||
}
|
||||
) => {
|
||||
const entity = relations.entitiesById[entityId] || null;
|
||||
if (!entity) return;
|
||||
let entity = relations.entitiesById[entityId] || null;
|
||||
let linkedWikis = relations.entityWikisById[entityId] || [];
|
||||
|
||||
if (!entity) {
|
||||
try {
|
||||
const { fetchEntityById } = await import("@/uhm/api/entities");
|
||||
entity = await fetchEntityById(entityId);
|
||||
const { fetchWikisByEntityIdsWithPreviews } = await import("@/uhm/api/relations");
|
||||
const wikisRes = await fetchWikisByEntityIdsWithPreviews([entityId]);
|
||||
linkedWikis = wikisRes[entityId] || [];
|
||||
|
||||
setRelations((prev) => {
|
||||
const wikiById = { ...prev.wikiById };
|
||||
const wikiBySlug = { ...prev.wikiBySlug };
|
||||
const wikiEntityIdsById = { ...prev.wikiEntityIdsById };
|
||||
const wikiEntityIdsBySlug = { ...prev.wikiEntityIdsBySlug };
|
||||
|
||||
for (const w of linkedWikis) {
|
||||
wikiById[w.id] = w;
|
||||
if (w.slug) {
|
||||
wikiBySlug[w.slug] = w;
|
||||
if (!wikiEntityIdsBySlug[w.slug]) wikiEntityIdsBySlug[w.slug] = [];
|
||||
if (!wikiEntityIdsBySlug[w.slug].includes(entityId)) {
|
||||
wikiEntityIdsBySlug[w.slug].push(entityId);
|
||||
}
|
||||
}
|
||||
if (!wikiEntityIdsById[w.id]) wikiEntityIdsById[w.id] = [];
|
||||
if (!wikiEntityIdsById[w.id].includes(entityId)) {
|
||||
wikiEntityIdsById[w.id].push(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
entitiesById: {
|
||||
...prev.entitiesById,
|
||||
[entityId]: entity,
|
||||
},
|
||||
entityWikisById: {
|
||||
...prev.entityWikisById,
|
||||
[entityId]: linkedWikis,
|
||||
},
|
||||
wikiById,
|
||||
wikiBySlug,
|
||||
wikiEntityIdsById,
|
||||
wikiEntityIdsBySlug,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to lazy load entity/wikis:", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const linkedWikis = relations.entityWikisById[entityId] || [];
|
||||
const preferredWikiSlug = String(selectOptions?.preferredWikiSlug || "").trim();
|
||||
if (!linkedWikis.length || (preferredWikiSlug && !linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug))) {
|
||||
try {
|
||||
const fetchedWikis = await fetchRelationWikisForEntity(entityId);
|
||||
if (fetchedWikis.length) {
|
||||
linkedWikis = fetchedWikis;
|
||||
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, fetchedWikis));
|
||||
setWikiCache((prev) => ({
|
||||
...wikisBySlug(fetchedWikis),
|
||||
...prev,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load entity wikis before selecting:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const nextWikiSlug =
|
||||
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
|
||||
? preferredWikiSlug
|
||||
@@ -113,7 +180,35 @@ export function usePublicPreviewInteraction(options: {
|
||||
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
|
||||
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
|
||||
}
|
||||
}, [relations.entitiesById, relations.entityWikisById, setSelectedFeatureIds]);
|
||||
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds]);
|
||||
|
||||
const selectWiki = useCallback(async (
|
||||
wiki: Wiki
|
||||
) => {
|
||||
const entityIds = relations.wikiEntityIdsById[wiki.id] || [];
|
||||
if (entityIds.length > 0) {
|
||||
await selectEntity(entityIds[0], {
|
||||
preferredWikiSlug: wiki.slug,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (wiki.slug) {
|
||||
const slug = wiki.slug;
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[slug]: {
|
||||
...wiki,
|
||||
__fetched: false,
|
||||
},
|
||||
}));
|
||||
setActiveWikiSlug(slug);
|
||||
}
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setIsManualSidebarOpen(true);
|
||||
}, [relations.wikiEntityIdsById, selectEntity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds.length) return;
|
||||
@@ -142,30 +237,15 @@ export function usePublicPreviewInteraction(options: {
|
||||
}));
|
||||
}
|
||||
|
||||
const rows = await Promise.all(
|
||||
wikis.map(async (wiki) => {
|
||||
const presetQuote = String(wiki.preview_quote || "").trim();
|
||||
const fullWiki = presetQuote ? wiki : await fetchFullWikiContent(wiki);
|
||||
const quote = presetQuote
|
||||
? cleanPreviewQuoteText(presetQuote)
|
||||
: extractWikiBlockquoteText(fullWiki.content);
|
||||
if (fullWiki.slug) {
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[String(fullWiki.slug)]: {
|
||||
...fullWiki,
|
||||
__fetched: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return { wiki: fullWiki, quote };
|
||||
})
|
||||
);
|
||||
const rows = wikis.map((wiki) => ({
|
||||
wiki,
|
||||
quote: cleanPreviewQuoteText(wiki.preview_quote),
|
||||
}));
|
||||
|
||||
setHoverWikiPreviewByEntityId((prev) => ({
|
||||
...prev,
|
||||
[entityId]: {
|
||||
rows: rows.filter((row) => row.quote.trim().length > 0),
|
||||
rows,
|
||||
isLoaded: true,
|
||||
},
|
||||
}));
|
||||
@@ -187,6 +267,64 @@ export function usePublicPreviewInteraction(options: {
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
if (!entities.length) return null;
|
||||
|
||||
type GroupedHoverRow = MapHoverPopupContent["rows"][number] & { isTimelineMatch: boolean };
|
||||
const groupedRows: GroupedHoverRow[] = entities.flatMap((entity): GroupedHoverRow[] => {
|
||||
const isTimelineMatch = isTimelineYearWithinEntityTimeRange(timelineYear, entity.time_start, entity.time_end);
|
||||
const preview = hoverWikiPreviewByEntityId[entity.id] ||
|
||||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
|
||||
if (!preview && !hoverWikiPreviewRequestsRef.current.has(entity.id)) {
|
||||
hoverWikiPreviewRequestsRef.current.add(entity.id);
|
||||
void loadHoverWikiPreviewForEntity(entity.id);
|
||||
}
|
||||
|
||||
const baseClick = (preferredWikiSlug: string | null = null) => {
|
||||
selectEntity(entity.id, {
|
||||
sourceFeatureId: featureId,
|
||||
preferredWikiSlug,
|
||||
selectGeometry: true,
|
||||
});
|
||||
};
|
||||
|
||||
const entityHeaderRow = {
|
||||
title: entity.name,
|
||||
description: entity.description,
|
||||
isGroupHeader: true,
|
||||
isTimelineMatch,
|
||||
};
|
||||
|
||||
if (preview?.rows.length) {
|
||||
return [
|
||||
entityHeaderRow,
|
||||
...preview.rows.map((row) => ({
|
||||
title: getWikiHoverTitle(row.wiki, entity.name),
|
||||
isTimelineMatch,
|
||||
quote: row.quote,
|
||||
onClick: () => baseClick(String(row.wiki.slug || "").trim() || null),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (preview?.isLoaded) {
|
||||
return [entityHeaderRow, {
|
||||
title: "(chưa có wiki)",
|
||||
titleTone: "danger",
|
||||
isTimelineMatch,
|
||||
quoteTone: "danger",
|
||||
}];
|
||||
}
|
||||
|
||||
return [entityHeaderRow, {
|
||||
title: "Đang tải wiki...",
|
||||
isTimelineMatch,
|
||||
quote: "Đang tải trích dẫn wiki...",
|
||||
onClick: () => baseClick(null),
|
||||
}];
|
||||
});
|
||||
|
||||
const timelineMatchedRows = groupedRows.filter((row) => row.isTimelineMatch);
|
||||
const otherRows = groupedRows.filter((row) => !row.isTimelineMatch);
|
||||
const stripGroupFlag = ({ isTimelineMatch: _isTimelineMatch, ...row }: GroupedHoverRow) => row;
|
||||
|
||||
return {
|
||||
key: entities
|
||||
.map((entity) => {
|
||||
@@ -194,40 +332,14 @@ export function usePublicPreviewInteraction(options: {
|
||||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
|
||||
return `${entity.id}:${preview?.isLoaded ? "loaded" : "loading"}:${preview?.rows.map((row) => row.quote).join("/") || ""}`;
|
||||
})
|
||||
.join("|"),
|
||||
rows: entities.flatMap((entity) => {
|
||||
const preview = hoverWikiPreviewByEntityId[entity.id] ||
|
||||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
|
||||
if (!preview && !hoverWikiPreviewRequestsRef.current.has(entity.id)) {
|
||||
hoverWikiPreviewRequestsRef.current.add(entity.id);
|
||||
void loadHoverWikiPreviewForEntity(entity.id);
|
||||
}
|
||||
|
||||
const baseClick = () => {
|
||||
const preferredWikiSlug = preview?.rows
|
||||
.map((row) => String(row.wiki.slug || "").trim())
|
||||
.find((slug) => slug.length > 0) || null;
|
||||
selectEntity(entity.id, {
|
||||
sourceFeatureId: featureId,
|
||||
preferredWikiSlug,
|
||||
selectGeometry: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (preview?.rows.length) {
|
||||
return preview.rows.map((row) => ({
|
||||
title: entity.name,
|
||||
quote: row.quote,
|
||||
onClick: baseClick,
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
title: entity.name,
|
||||
quote: preview?.isLoaded ? "" : "Đang tải trích dẫn wiki...",
|
||||
onClick: baseClick,
|
||||
}];
|
||||
}),
|
||||
.join("|") + `:${timelineYear ?? "none"}`,
|
||||
rows: [
|
||||
...timelineMatchedRows.map(stripGroupFlag),
|
||||
...otherRows.map((row, index) => ({
|
||||
...stripGroupFlag(row),
|
||||
separatorBefore: index === 0 && timelineMatchedRows.length > 0,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}, [
|
||||
hoverWikiPreviewByEntityId,
|
||||
@@ -236,6 +348,7 @@ export function usePublicPreviewInteraction(options: {
|
||||
relations.entityWikisById,
|
||||
relations.geometryEntityIds,
|
||||
selectEntity,
|
||||
timelineYear,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -268,9 +381,6 @@ export function usePublicPreviewInteraction(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedWikiEntityIdsRef.current.has(activeEntityId)) return;
|
||||
loadedWikiEntityIdsRef.current.add(activeEntityId);
|
||||
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
setIsActiveWikiLoading(true);
|
||||
@@ -295,7 +405,6 @@ export function usePublicPreviewInteraction(options: {
|
||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||
}
|
||||
} catch (err) {
|
||||
loadedWikiEntityIdsRef.current.delete(activeEntityId);
|
||||
if (!disposed) {
|
||||
console.error("Load entity wikis failed", err);
|
||||
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki cho entity đã chọn.");
|
||||
@@ -420,6 +529,7 @@ export function usePublicPreviewInteraction(options: {
|
||||
linkEntityPopupRef,
|
||||
getHoverPopupContent,
|
||||
selectEntity,
|
||||
selectWiki,
|
||||
handleWikiLinkRequest,
|
||||
closeWikiSidebar,
|
||||
setLinkEntityPopup,
|
||||
@@ -459,31 +569,10 @@ function buildPresetHoverPreview(wikis: Wiki[]): HoverWikiPreview | undefined {
|
||||
.map((wiki) => ({
|
||||
wiki,
|
||||
quote: cleanPreviewQuoteText(wiki.preview_quote),
|
||||
}))
|
||||
.filter((row) => row.quote.length > 0);
|
||||
}));
|
||||
return rows.length ? { rows, isLoaded: true } : undefined;
|
||||
}
|
||||
|
||||
async function fetchFullWikiContent(wiki: Wiki): Promise<Wiki> {
|
||||
const slug = String(wiki.slug || "").trim();
|
||||
let row = wiki;
|
||||
if (slug) {
|
||||
row = await fetchWikiBySlug(slug) || wiki;
|
||||
}
|
||||
|
||||
let versionContent = row.content;
|
||||
try {
|
||||
if (row.content_sample?.[0]?.id) {
|
||||
const res = await getContentByVersionWikiId(row.content_sample[0].id);
|
||||
if (res?.data?.content) versionContent = res.data.content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch hover wiki version content:", err);
|
||||
}
|
||||
|
||||
return { ...row, content: versionContent };
|
||||
}
|
||||
|
||||
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
@@ -555,6 +644,10 @@ function firstWikiSlug(wikis: Wiki[]): string | null {
|
||||
return wikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) || null;
|
||||
}
|
||||
|
||||
function getWikiHoverTitle(wiki: Wiki | null | undefined, fallbackTitle: string): string {
|
||||
return String(wiki?.title || "").trim() || fallbackTitle;
|
||||
}
|
||||
|
||||
function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
|
||||
@@ -447,38 +447,6 @@ export default function TimelineBar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onPlayReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayReplay}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#2563eb",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Xem diễn biến lịch sử (Replay)"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{typeof timeRange === "number" && onTimeRangeChange ? (
|
||||
<label
|
||||
title="time_range (0-30)"
|
||||
|
||||
@@ -319,8 +319,8 @@ function PublicWikiSidebar({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: isMobileOrTablet ? "100%" : `${width}px`,
|
||||
width: isMobileOrTablet ? "100%" : `${width}px`,
|
||||
maxWidth: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
|
||||
@@ -1223,8 +1223,9 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
|
||||
const data = params[0];
|
||||
if (data && typeof data === "object") {
|
||||
hasDialog = true;
|
||||
text = String((data as any).text || text);
|
||||
image_url = String((data as any).image_url || image_url);
|
||||
const dialogData = data as { text?: unknown; image_url?: unknown };
|
||||
text = String(dialogData.text || text);
|
||||
image_url = String(dialogData.image_url || image_url);
|
||||
} else if (data === null) {
|
||||
isCleared = true;
|
||||
}
|
||||
@@ -1271,7 +1272,10 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
|
||||
}
|
||||
|
||||
if (hasDialog) {
|
||||
const dialogData: any = {
|
||||
const dialogData: {
|
||||
text: string;
|
||||
image_url?: string;
|
||||
} = {
|
||||
text,
|
||||
};
|
||||
if (image_url) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||
import { snapToNearestGeometryDetailed, tracePathBetweenPoints, getRingWithSnaps } from "@/uhm/lib/map/engines/snapUtils";
|
||||
import { getSnapVertexCoordinate, snapToNearestGeometryDetailed, tracePathBetweenPoints } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||
export function initDrawing(
|
||||
@@ -21,12 +21,7 @@ export function initDrawing(
|
||||
startIdx: number;
|
||||
targetFeatureId: string | number;
|
||||
targetFeatureRing: [number, number][];
|
||||
snap1: {
|
||||
type: "vertex" | "edge";
|
||||
vertexIdx?: number;
|
||||
edgeIdx?: number;
|
||||
lngLat: { lng: number; lat: number };
|
||||
};
|
||||
targetVertexIdx: number;
|
||||
} | null = null;
|
||||
|
||||
const clearPreview = () => {
|
||||
@@ -107,30 +102,16 @@ export function initDrawing(
|
||||
if (traceStartState) {
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
|
||||
if (
|
||||
targetSnap.type !== "none" &&
|
||||
targetSnap.type === "vertex" &&
|
||||
targetSnap.featureId !== undefined &&
|
||||
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
|
||||
targetSnap.ringCoords
|
||||
targetSnap.vertexIdx !== undefined
|
||||
) {
|
||||
// Hợp lệ, tiến hành trace dọc biên giới
|
||||
const snap1 = traceStartState.snap1;
|
||||
const snap2 = {
|
||||
type: targetSnap.type as "vertex" | "edge",
|
||||
vertexIdx: targetSnap.vertexIdx,
|
||||
edgeIdx: targetSnap.edgeIdx,
|
||||
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
|
||||
};
|
||||
|
||||
const { ring, idx1, idx2 } = getRingWithSnaps(
|
||||
traceStartState.targetFeatureRing,
|
||||
snap1,
|
||||
snap2
|
||||
);
|
||||
|
||||
// Hợp lệ, trace dọc các vertex gốc của biên giới.
|
||||
const path = tracePathBetweenPoints(
|
||||
ring as [number, number][],
|
||||
idx1,
|
||||
idx2
|
||||
traceStartState.targetFeatureRing,
|
||||
traceStartState.targetVertexIdx,
|
||||
targetSnap.vertexIdx
|
||||
);
|
||||
|
||||
if (path.length > 0) {
|
||||
@@ -141,7 +122,12 @@ export function initDrawing(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Không tìm thấy điểm kết thúc hợp lệ trên cùng Geo, đặt điểm vẽ tự do bình thường
|
||||
if (e.originalEvent.shiftKey) {
|
||||
update(coords);
|
||||
return;
|
||||
}
|
||||
|
||||
// Không tìm thấy vertex kết thúc hợp lệ trên cùng Geo, đặt điểm vẽ tự do bình thường
|
||||
coords.push(currentPoint);
|
||||
coordMeta.push({ isTrace: false });
|
||||
}
|
||||
@@ -152,23 +138,32 @@ export function initDrawing(
|
||||
|
||||
// 2. Nếu chưa có trace, kiểm tra xem click này có kích hoạt tạo điểm bắt đầu trace không (Shift + T)
|
||||
const isShiftT = e.originalEvent.shiftKey && isTKeyDown;
|
||||
if (isShiftT && snapRes && snapRes.type !== "none" && snapRes.featureId !== undefined && snapRes.ringCoords) {
|
||||
coords.push(currentPoint);
|
||||
const traceStartCoordinate = snapRes ? getSnapVertexCoordinate(snapRes) : null;
|
||||
if (
|
||||
isShiftT &&
|
||||
snapRes &&
|
||||
snapRes.type === "vertex" &&
|
||||
snapRes.featureId !== undefined &&
|
||||
snapRes.ringCoords &&
|
||||
snapRes.vertexIdx !== undefined &&
|
||||
traceStartCoordinate
|
||||
) {
|
||||
coords.push(traceStartCoordinate);
|
||||
coordMeta.push({ isTrace: false }); // start point của trace vẫn tính là điểm bình thường
|
||||
|
||||
traceStartState = {
|
||||
startCoord: currentPoint,
|
||||
startCoord: traceStartCoordinate,
|
||||
startIdx: coords.length - 1,
|
||||
targetFeatureId: snapRes.featureId,
|
||||
targetFeatureRing: snapRes.ringCoords as [number, number][],
|
||||
snap1: {
|
||||
type: snapRes.type as "vertex" | "edge",
|
||||
vertexIdx: snapRes.vertexIdx,
|
||||
edgeIdx: snapRes.edgeIdx,
|
||||
lngLat: { lng: snapRes.lngLat.lng, lat: snapRes.lngLat.lat }
|
||||
}
|
||||
targetVertexIdx: snapRes.vertexIdx
|
||||
};
|
||||
} else {
|
||||
if (isShiftT) {
|
||||
update(coords);
|
||||
return;
|
||||
}
|
||||
|
||||
// Click bình thường
|
||||
coords.push(currentPoint);
|
||||
coordMeta.push({ isTrace: false });
|
||||
@@ -196,35 +191,19 @@ export function initDrawing(
|
||||
if (traceStartState) {
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
|
||||
if (
|
||||
targetSnap.type !== "none" &&
|
||||
targetSnap.type === "vertex" &&
|
||||
targetSnap.featureId !== undefined &&
|
||||
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
|
||||
targetSnap.ringCoords
|
||||
targetSnap.vertexIdx !== undefined
|
||||
) {
|
||||
const snap1 = traceStartState.snap1;
|
||||
const snap2 = {
|
||||
type: targetSnap.type as "vertex" | "edge",
|
||||
vertexIdx: targetSnap.vertexIdx,
|
||||
edgeIdx: targetSnap.edgeIdx,
|
||||
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
|
||||
};
|
||||
|
||||
const { ring, idx1, idx2 } = getRingWithSnaps(
|
||||
traceStartState.targetFeatureRing,
|
||||
snap1,
|
||||
snap2
|
||||
);
|
||||
|
||||
const path = tracePathBetweenPoints(
|
||||
ring as [number, number][],
|
||||
idx1,
|
||||
idx2
|
||||
traceStartState.targetFeatureRing,
|
||||
traceStartState.targetVertexIdx,
|
||||
targetSnap.vertexIdx
|
||||
);
|
||||
|
||||
if (path.length > 0) {
|
||||
const previewCoords = [...coords];
|
||||
const traceStartOffset = coords.length;
|
||||
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
previewCoords.push(path[i]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
|
||||
import { snapToNearestGeometry, snapToNearestGeometryDetailed, getRingWithSnaps, tracePathBetweenPoints } from "@/uhm/lib/map/engines/snapUtils";
|
||||
import { getSnapVertexCoordinate, snapToNearestGeometry, snapToNearestGeometryDetailed, tracePathBetweenPoints } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
const HANDLE_VERTEX_MATCH_EPSILON_DEGREES = 1e-12;
|
||||
const HANDLE_EDGE_MATCH_EPSILON_METERS = 0.05;
|
||||
const SNAP_STATUS_SOURCE_IDS = ["countries", "places"] as const;
|
||||
|
||||
type HandleStatus = "unknown" | "none" | "vertex" | "edge" | "delete";
|
||||
|
||||
export type EditingHandle = {
|
||||
id: string | number;
|
||||
@@ -29,7 +35,10 @@ export function createEditingEngine(options: {
|
||||
const editingRef = { current: null as EditingHandle | null };
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
const deleteVertexModeRef = { current: false };
|
||||
let vertexSnapStatuses: ("vertex" | "edge" | "none")[] = [];
|
||||
let vertexSnapCache = new WeakMap<[number, number], HandleStatus>();
|
||||
let lastHandlesJson = "";
|
||||
let lastShapeJson = "";
|
||||
let needsCacheClear = false;
|
||||
let deleteRangeStartIdx: number | null = null;
|
||||
let deleteRangeHoverIdx: number | null = null;
|
||||
let deleteRangeIndices: number[] = [];
|
||||
@@ -37,6 +46,7 @@ export function createEditingEngine(options: {
|
||||
let lastMousePointPx: maplibregl.Point | null = null;
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
let pendingSnapStatusRefresh = false;
|
||||
|
||||
// Trạng thái vẽ tiếp (Continue Draw) để vẽ nối tiếp/sửa từ một đỉnh
|
||||
let isDrawingContinued = false;
|
||||
@@ -48,12 +58,7 @@ export function createEditingEngine(options: {
|
||||
startIdx: number;
|
||||
targetFeatureId: string | number;
|
||||
targetFeatureRing: [number, number][];
|
||||
snap1: {
|
||||
type: "vertex" | "edge";
|
||||
vertexIdx?: number;
|
||||
edgeIdx?: number;
|
||||
lngLat: { lng: number; lat: number };
|
||||
};
|
||||
targetVertexIdx: number;
|
||||
} | null = null;
|
||||
let currentTraceGroupId = 1;
|
||||
|
||||
@@ -73,7 +78,10 @@ export function createEditingEngine(options: {
|
||||
}
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
vertexSnapStatuses = [];
|
||||
vertexSnapCache = new WeakMap();
|
||||
lastHandlesJson = "";
|
||||
lastShapeJson = "";
|
||||
needsCacheClear = false;
|
||||
setDeleteVertexMode(false);
|
||||
hideContextMenu();
|
||||
const map = mapRef.current;
|
||||
@@ -83,12 +91,41 @@ export function createEditingEngine(options: {
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
};
|
||||
|
||||
const coordinatesAlmostEqual = (
|
||||
a: [number, number],
|
||||
b: [number, number],
|
||||
epsilon: number
|
||||
) => Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon;
|
||||
|
||||
const areSnapStatusSourcesReady = (map: maplibregl.Map) => {
|
||||
if (map.isMoving() || !map.areTilesLoaded()) return false;
|
||||
return SNAP_STATUS_SOURCE_IDS.every((sourceId) => {
|
||||
if (!map.getSource(sourceId)) return false;
|
||||
return map.isSourceLoaded(sourceId);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleSnapStatusRefresh = (map: maplibregl.Map) => {
|
||||
if (pendingSnapStatusRefresh) return;
|
||||
pendingSnapStatusRefresh = true;
|
||||
map.once("idle", () => {
|
||||
pendingSnapStatusRefresh = false;
|
||||
updateEditSources();
|
||||
});
|
||||
};
|
||||
|
||||
// Đồng bộ polygon/line/point tạm và các handle point lên map source.
|
||||
const updateEditSources = () => {
|
||||
const editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
console.log("updateEditSources: editing:", editing, "map loaded:", map?.isStyleLoaded());
|
||||
if (!editing || !map || !map.isStyleLoaded()) return;
|
||||
const snapSourcesReady = areSnapStatusSourcesReady(map);
|
||||
if (!snapSourcesReady) {
|
||||
scheduleSnapStatusRefresh(map);
|
||||
} else if (needsCacheClear) {
|
||||
vertexSnapCache = new WeakMap();
|
||||
needsCacheClear = false;
|
||||
}
|
||||
|
||||
let shape: GeoJSON.FeatureCollection<GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point>;
|
||||
let handles: GeoJSON.FeatureCollection<GeoJSON.Point>;
|
||||
@@ -96,7 +133,7 @@ export function createEditingEngine(options: {
|
||||
const geomType = editing.geometryType || "Polygon";
|
||||
|
||||
const getHandleProperties = (idx: number, coordinate: [number, number], extraProps = {}) => {
|
||||
let status: "none" | "vertex" | "edge" | "delete" = "none";
|
||||
let status: HandleStatus = snapSourcesReady ? "none" : "unknown";
|
||||
if (deleteVertexModeRef.current) {
|
||||
if (deleteRangeStartIdx !== null) {
|
||||
if (idx === deleteRangeStartIdx || idx === deleteRangeHoverIdx) {
|
||||
@@ -110,28 +147,32 @@ export function createEditingEngine(options: {
|
||||
status = "delete";
|
||||
}
|
||||
} else {
|
||||
const isDragging = dragStateRef.current !== null;
|
||||
const isDraggedVertex = dragStateRef.current?.idx === idx;
|
||||
|
||||
if (isDragging && !isDraggedVertex && vertexSnapStatuses[idx]) {
|
||||
status = vertexSnapStatuses[idx];
|
||||
if (!isDraggedVertex && vertexSnapCache.has(coordinate) && vertexSnapCache.get(coordinate) !== "unknown") {
|
||||
status = vertexSnapCache.get(coordinate)!;
|
||||
} else if (!snapSourcesReady) {
|
||||
status = "unknown";
|
||||
} else {
|
||||
const lngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
|
||||
const pointPx = map.project(lngLat);
|
||||
const snapResult = snapToNearestGeometryDetailed(map, lngLat, pointPx, editing.id);
|
||||
|
||||
if (snapResult.type !== "none") {
|
||||
if (snapResult.type === "vertex") {
|
||||
const snapCoordinate = getSnapVertexCoordinate(snapResult);
|
||||
status = snapCoordinate && coordinatesAlmostEqual(
|
||||
coordinate,
|
||||
snapCoordinate,
|
||||
HANDLE_VERTEX_MATCH_EPSILON_DEGREES
|
||||
) ? "vertex" : "none";
|
||||
} else if (snapResult.type === "edge") {
|
||||
const dist = distanceMeters(coordinate, [snapResult.lngLat.lng, snapResult.lngLat.lat]);
|
||||
if (dist <= 1.0) {
|
||||
status = snapResult.type;
|
||||
} else {
|
||||
status = "none";
|
||||
}
|
||||
status = dist <= HANDLE_EDGE_MATCH_EPSILON_METERS ? "edge" : "none";
|
||||
} else {
|
||||
status = "none";
|
||||
}
|
||||
|
||||
vertexSnapStatuses[idx] = status;
|
||||
vertexSnapCache.set(coordinate, status);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -238,8 +279,17 @@ export function createEditingEngine(options: {
|
||||
};
|
||||
}
|
||||
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||
const shapeJson = JSON.stringify(shape);
|
||||
if (shapeJson !== lastShapeJson) {
|
||||
lastShapeJson = shapeJson;
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||
}
|
||||
|
||||
const handlesJson = JSON.stringify(handles);
|
||||
if (handlesJson !== lastHandlesJson) {
|
||||
lastHandlesJson = handlesJson;
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||
}
|
||||
};
|
||||
|
||||
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||
@@ -297,16 +347,12 @@ export function createEditingEngine(options: {
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon/line/point được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
console.log("beginEditing called with feature:", feature);
|
||||
if (!feature || !feature.geometry) {
|
||||
console.warn("beginEditing: feature or feature.geometry is missing");
|
||||
return;
|
||||
}
|
||||
const geom = feature.geometry as Geometry;
|
||||
const type = geom.type;
|
||||
console.log("beginEditing: geometry type is", type);
|
||||
if (type !== "Polygon" && type !== "LineString" && type !== "Point") {
|
||||
console.warn("beginEditing: unsupported geometry type:", type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -315,26 +361,20 @@ export function createEditingEngine(options: {
|
||||
let ring: [number, number][] = [];
|
||||
if (type === "Polygon") {
|
||||
const coords = (geom.coordinates?.[0] ?? []) as [number, number][];
|
||||
console.log("beginEditing Polygon coords:", coords);
|
||||
if (coords.length < 4) {
|
||||
console.warn("beginEditing: Polygon coords length is less than 4");
|
||||
return;
|
||||
}
|
||||
// remove duplicated closing point
|
||||
ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||
} else if (type === "LineString") {
|
||||
const coords = (geom.coordinates ?? []) as [number, number][];
|
||||
console.log("beginEditing LineString coords:", coords);
|
||||
if (coords.length < 2) {
|
||||
console.warn("beginEditing: LineString coords length is less than 2");
|
||||
return;
|
||||
}
|
||||
ring = coords.map((c) => [c[0], c[1]] as [number, number]);
|
||||
} else if (type === "Point") {
|
||||
const coords = (geom.coordinates ?? []) as [number, number];
|
||||
console.log("beginEditing Point coords:", coords);
|
||||
if (coords.length < 2) {
|
||||
console.warn("beginEditing: Point coords length is less than 2");
|
||||
return;
|
||||
}
|
||||
ring = [[coords[0], coords[1]]];
|
||||
@@ -349,7 +389,8 @@ export function createEditingEngine(options: {
|
||||
circleRadius: geom.circle_radius,
|
||||
geometryType: type,
|
||||
};
|
||||
console.log("beginEditing: initialized editingRef.current:", editingRef.current);
|
||||
vertexSnapCache = new WeakMap();
|
||||
needsCacheClear = false;
|
||||
setDeleteVertexMode(false);
|
||||
updateEditSources();
|
||||
};
|
||||
@@ -450,7 +491,6 @@ export function createEditingEngine(options: {
|
||||
const sortedIndices = [...deleteRangeIndices].sort((a, b) => b - a);
|
||||
for (const idx of sortedIndices) {
|
||||
editing.ring.splice(idx, 1);
|
||||
vertexSnapStatuses.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,18 +672,12 @@ export function createEditingEngine(options: {
|
||||
finishEditing();
|
||||
} else if (e.key === "Delete" && editing.geometryType !== "Point" && !editing.isCircle) {
|
||||
e.preventDefault();
|
||||
if (e.repeat) return;
|
||||
setDeleteVertexMode(!deleteVertexModeRef.current);
|
||||
} else if (e.key === "Escape") {
|
||||
if (deleteVertexModeRef.current) {
|
||||
e.preventDefault();
|
||||
if (deleteRangeStartIdx !== null) {
|
||||
deleteRangeStartIdx = null;
|
||||
deleteRangeHoverIdx = null;
|
||||
deleteRangeIndices = [];
|
||||
updateEditSources();
|
||||
} else {
|
||||
setDeleteVertexMode(false);
|
||||
}
|
||||
setDeleteVertexMode(false);
|
||||
return;
|
||||
}
|
||||
cancelEditing();
|
||||
@@ -688,6 +722,12 @@ export function createEditingEngine(options: {
|
||||
}
|
||||
};
|
||||
|
||||
const onMapViewportChange = () => {
|
||||
if (!editingRef.current) return;
|
||||
needsCacheClear = true;
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.on("contextmenu", "edit-handles-circle", onHandleContextMenu);
|
||||
map.on("mouseenter", "edit-handles-circle", onHandleMouseEnter);
|
||||
@@ -696,14 +736,14 @@ export function createEditingEngine(options: {
|
||||
map.on("click", onGeneralMapClick);
|
||||
map.on("mousemove", onHandleMove);
|
||||
map.on("mouseup", stopDragging);
|
||||
map.on("moveend", onMapViewportChange);
|
||||
map.on("zoomend", onMapViewportChange);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onWindowBlur);
|
||||
|
||||
const canvas = map.getCanvas();
|
||||
if (canvas) {
|
||||
canvas.addEventListener("keydown", onKeyDown);
|
||||
canvas.addEventListener("keyup", onKeyUp);
|
||||
canvas.addEventListener("mouseleave", onCanvasLeave);
|
||||
}
|
||||
|
||||
@@ -719,14 +759,14 @@ export function createEditingEngine(options: {
|
||||
map.off("click", onGeneralMapClick);
|
||||
map.off("mousemove", onHandleMove);
|
||||
map.off("mouseup", stopDragging);
|
||||
map.off("moveend", onMapViewportChange);
|
||||
map.off("zoomend", onMapViewportChange);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onWindowBlur);
|
||||
try {
|
||||
const canvas = map.getCanvas();
|
||||
if (canvas) {
|
||||
canvas.removeEventListener("keydown", onKeyDown);
|
||||
canvas.removeEventListener("keyup", onKeyUp);
|
||||
canvas.removeEventListener("mouseleave", onCanvasLeave);
|
||||
}
|
||||
} catch {
|
||||
@@ -774,6 +814,8 @@ export function createEditingEngine(options: {
|
||||
};
|
||||
}
|
||||
|
||||
lastShapeJson = "";
|
||||
lastHandlesJson = "";
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
@@ -929,7 +971,7 @@ export function createEditingEngine(options: {
|
||||
map,
|
||||
e.lngLat,
|
||||
e.point,
|
||||
null,
|
||||
editing.id,
|
||||
traceStartState ? traceStartState.targetFeatureId : null
|
||||
)
|
||||
: null;
|
||||
@@ -942,31 +984,17 @@ export function createEditingEngine(options: {
|
||||
|
||||
// 1. Thử chốt trace dọc biên giới
|
||||
if (traceStartState) {
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, editing.id, traceStartState.targetFeatureId);
|
||||
if (
|
||||
targetSnap.type !== "none" &&
|
||||
targetSnap.type === "vertex" &&
|
||||
targetSnap.featureId !== undefined &&
|
||||
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
|
||||
targetSnap.ringCoords
|
||||
targetSnap.vertexIdx !== undefined
|
||||
) {
|
||||
const snap1 = traceStartState.snap1;
|
||||
const snap2 = {
|
||||
type: targetSnap.type as "vertex" | "edge",
|
||||
vertexIdx: targetSnap.vertexIdx,
|
||||
edgeIdx: targetSnap.edgeIdx,
|
||||
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
|
||||
};
|
||||
|
||||
const { ring, idx1, idx2 } = getRingWithSnaps(
|
||||
traceStartState.targetFeatureRing,
|
||||
snap1,
|
||||
snap2
|
||||
);
|
||||
|
||||
const path = tracePathBetweenPoints(
|
||||
ring as [number, number][],
|
||||
idx1,
|
||||
idx2
|
||||
traceStartState.targetFeatureRing,
|
||||
traceStartState.targetVertexIdx,
|
||||
targetSnap.vertexIdx
|
||||
);
|
||||
|
||||
if (path.length > 0) {
|
||||
@@ -985,27 +1013,40 @@ export function createEditingEngine(options: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.originalEvent.shiftKey) {
|
||||
updateEditSources();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shift + T để kích hoạt start trace
|
||||
const isShiftT = e.originalEvent.shiftKey && isTKeyDown;
|
||||
if (isShiftT && snapRes && snapRes.type !== "none" && snapRes.featureId !== undefined && snapRes.ringCoords) {
|
||||
drawnPoints.push(currentPoint);
|
||||
const traceStartCoordinate = snapRes ? getSnapVertexCoordinate(snapRes) : null;
|
||||
if (
|
||||
isShiftT &&
|
||||
snapRes &&
|
||||
snapRes.type === "vertex" &&
|
||||
snapRes.featureId !== undefined &&
|
||||
snapRes.ringCoords &&
|
||||
snapRes.vertexIdx !== undefined &&
|
||||
traceStartCoordinate
|
||||
) {
|
||||
drawnPoints.push(traceStartCoordinate);
|
||||
coordMeta.push({ isTrace: false });
|
||||
|
||||
traceStartState = {
|
||||
startCoord: currentPoint,
|
||||
startCoord: traceStartCoordinate,
|
||||
startIdx: drawnPoints.length - 1,
|
||||
targetFeatureId: snapRes.featureId,
|
||||
targetFeatureRing: snapRes.ringCoords as [number, number][],
|
||||
snap1: {
|
||||
type: snapRes.type as "vertex" | "edge",
|
||||
vertexIdx: snapRes.vertexIdx,
|
||||
edgeIdx: snapRes.edgeIdx,
|
||||
lngLat: { lng: snapRes.lngLat.lng, lat: snapRes.lngLat.lat }
|
||||
}
|
||||
targetVertexIdx: snapRes.vertexIdx
|
||||
};
|
||||
} else {
|
||||
if (isShiftT) {
|
||||
updateEditSources();
|
||||
return;
|
||||
}
|
||||
|
||||
drawnPoints.push(currentPoint);
|
||||
coordMeta.push({ isTrace: false });
|
||||
traceStartState = null;
|
||||
@@ -1029,7 +1070,7 @@ export function createEditingEngine(options: {
|
||||
map,
|
||||
e.lngLat,
|
||||
e.point,
|
||||
null,
|
||||
editing.id,
|
||||
traceStartState ? traceStartState.targetFeatureId : null
|
||||
)
|
||||
: null;
|
||||
@@ -1043,31 +1084,17 @@ export function createEditingEngine(options: {
|
||||
|
||||
// Nếu đang trong quá trình trace, tìm đường đi nháp
|
||||
if (traceStartState) {
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
|
||||
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, editing.id, traceStartState.targetFeatureId);
|
||||
if (
|
||||
targetSnap.type !== "none" &&
|
||||
targetSnap.type === "vertex" &&
|
||||
targetSnap.featureId !== undefined &&
|
||||
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
|
||||
targetSnap.ringCoords
|
||||
targetSnap.vertexIdx !== undefined
|
||||
) {
|
||||
const snap1 = traceStartState.snap1;
|
||||
const snap2 = {
|
||||
type: targetSnap.type as "vertex" | "edge",
|
||||
vertexIdx: targetSnap.vertexIdx,
|
||||
edgeIdx: targetSnap.edgeIdx,
|
||||
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
|
||||
};
|
||||
|
||||
const { ring, idx1, idx2 } = getRingWithSnaps(
|
||||
traceStartState.targetFeatureRing,
|
||||
snap1,
|
||||
snap2
|
||||
);
|
||||
|
||||
const path = tracePathBetweenPoints(
|
||||
ring as [number, number][],
|
||||
idx1,
|
||||
idx2
|
||||
traceStartState.targetFeatureRing,
|
||||
traceStartState.targetVertexIdx,
|
||||
targetSnap.vertexIdx
|
||||
);
|
||||
|
||||
if (path.length > 0) {
|
||||
@@ -1140,7 +1167,7 @@ export function createEditingEngine(options: {
|
||||
): [number, number][] => {
|
||||
if (!continueDrawConfig) return activeDrawn;
|
||||
|
||||
let combined = prefix.concat(activeDrawn).concat(suffix);
|
||||
const combined = prefix.concat(activeDrawn).concat(suffix);
|
||||
|
||||
// Đối với polygon khép kín nếu bị quấn vòng qua điểm bắt đầu/kết thúc
|
||||
if (!isLine && combined.length > 1) {
|
||||
@@ -1292,7 +1319,6 @@ export function createEditingEngine(options: {
|
||||
if (editing.ring.length <= minLength) return;
|
||||
if (idx < 0 || idx >= editing.ring.length) return;
|
||||
editing.ring.splice(idx, 1);
|
||||
vertexSnapStatuses.splice(idx, 1);
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
@@ -1310,7 +1336,6 @@ export function createEditingEngine(options: {
|
||||
(current[1] + prev[1]) / 2,
|
||||
];
|
||||
editing.ring.splice(idx, 0, midpoint);
|
||||
vertexSnapStatuses.splice(idx, 0, "none");
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
@@ -1328,7 +1353,6 @@ export function createEditingEngine(options: {
|
||||
(current[1] + next[1]) / 2,
|
||||
];
|
||||
editing.ring.splice(idx + 1, 0, midpoint);
|
||||
vertexSnapStatuses.splice(idx + 1, 0, "none");
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function initSelect(
|
||||
}
|
||||
}
|
||||
|
||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||
// Chọn hoặc toggle đối tượng; giữ Shift để chọn cộng dồn/tắt chọn.
|
||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||
const id = feature.properties?.id ?? feature.id;
|
||||
if (id === undefined || id === null) return false;
|
||||
@@ -61,7 +61,7 @@ export function initSelect(
|
||||
const isAlreadySelected = idToRemove !== undefined;
|
||||
|
||||
if (additive && isAlreadySelected) {
|
||||
// Alt + click on an already selected feature removes it from the selection
|
||||
// Shift + click on an already selected feature removes it from the selection
|
||||
setSelectionStateForId(idToRemove, false);
|
||||
selectedIds.delete(idToRemove);
|
||||
onSelectIds?.(Array.from(selectedIds));
|
||||
@@ -74,7 +74,7 @@ export function initSelect(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Shift.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
const mode = getMode();
|
||||
if (mode !== "select" && mode !== "replay" && mode !== "preview" && mode !== "replay_preview") return;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
|
||||
// SHIFT/ALT snap should be forgiving while drawing quickly.
|
||||
// Vertices get a larger radius and always win over edges when both are available.
|
||||
const VERTEX_SNAP_THRESHOLD_PX = 34;
|
||||
const EDGE_SNAP_THRESHOLD_PX = 24;
|
||||
const QUERY_THRESHOLD_PX = Math.max(VERTEX_SNAP_THRESHOLD_PX, EDGE_SNAP_THRESHOLD_PX);
|
||||
const COORDINATE_EPSILON = 1e-10;
|
||||
const SEGMENT_ENDPOINT_EPSILON = 1e-7;
|
||||
|
||||
type Coordinate = [number, number];
|
||||
type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollection> & {
|
||||
@@ -21,6 +22,12 @@ export type SnapResult = {
|
||||
edgeIdx?: number;
|
||||
};
|
||||
|
||||
export function getSnapVertexCoordinate(snap: SnapResult): [number, number] | null {
|
||||
if (snap.type !== "vertex" || snap.vertexIdx === undefined || !snap.ringCoords) return null;
|
||||
const coordinate = snap.ringCoords[snap.vertexIdx];
|
||||
return coordinate ? [coordinate[0], coordinate[1]] : null;
|
||||
}
|
||||
|
||||
export function snapToNearestGeometry(
|
||||
map: maplibregl.Map,
|
||||
lngLat: maplibregl.LngLat,
|
||||
@@ -65,21 +72,28 @@ export function snapToNearestGeometryDetailed(
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
|
||||
};
|
||||
|
||||
// Tìm điểm gần nhất trên đoạn thẳng [a, b] so với điểm p (tính trên pixel màn hình)
|
||||
const getClosestPointOnSegment = (p: maplibregl.Point, a: maplibregl.Point, b: maplibregl.Point): maplibregl.Point => {
|
||||
// Tìm t của điểm gần nhất trên đoạn thẳng [a, b] so với điểm p (tính trên pixel màn hình)
|
||||
const getClosestTOnSegment = (p: maplibregl.Point, a: maplibregl.Point, b: maplibregl.Point): number => {
|
||||
const atob = { x: b.x - a.x, y: b.y - a.y };
|
||||
const atop = { x: p.x - a.x, y: p.y - a.y };
|
||||
const lenSq = atob.x * atob.x + atob.y * atob.y;
|
||||
if (lenSq === 0) return new maplibregl.Point(a.x, a.y);
|
||||
if (lenSq === 0) return 0;
|
||||
|
||||
let t = (atop.x * atob.x + atop.y * atob.y) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
|
||||
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
|
||||
|
||||
return t;
|
||||
};
|
||||
|
||||
// Tìm điểm gần nhất trên đoạn thẳng kinh vĩ độ [a, b] so với tọa độ con trỏ p (bảo toàn độ chính xác 64-bit)
|
||||
const getClosestPointOnLngLatSegment = (p: maplibregl.LngLat, a: Coordinate, b: Coordinate): maplibregl.LngLat => {
|
||||
const getPointOnSegment = (a: maplibregl.Point, b: maplibregl.Point, t: number): maplibregl.Point => {
|
||||
return new maplibregl.Point(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
};
|
||||
|
||||
// Nội suy trên Mercator bằng đúng t pixel đã chọn để điểm snap nằm ổn định trên đoạn gốc.
|
||||
const getPointOnLngLatSegment = (a: Coordinate, b: Coordinate, t: number): maplibregl.LngLat => {
|
||||
if (t <= SEGMENT_ENDPOINT_EPSILON) return new maplibregl.LngLat(a[0], a[1]);
|
||||
if (t >= 1 - SEGMENT_ENDPOINT_EPSILON) return new maplibregl.LngLat(b[0], b[1]);
|
||||
|
||||
const toMercatorY = (lat: number) => {
|
||||
if (lat > 85.0511) lat = 85.0511;
|
||||
if (lat < -85.0511) lat = -85.0511;
|
||||
@@ -90,21 +104,13 @@ export function snapToNearestGeometryDetailed(
|
||||
return (360 / Math.PI) * Math.atan(Math.exp(y)) - 90;
|
||||
};
|
||||
|
||||
const ax = a[0], ay = toMercatorY(a[1]);
|
||||
const bx = b[0], by = toMercatorY(b[1]);
|
||||
const px = p.lng, py = toMercatorY(p.lat);
|
||||
const ax = a[0];
|
||||
const ay = toMercatorY(a[1]);
|
||||
const bx = b[0];
|
||||
const by = toMercatorY(b[1]);
|
||||
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
|
||||
if (lenSq === 0) return new maplibregl.LngLat(a[0], a[1]);
|
||||
|
||||
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
|
||||
const resultLng = ax + dx * t;
|
||||
const resultLat = fromMercatorY(ay + dy * t);
|
||||
const resultLng = ax + (bx - ax) * t;
|
||||
const resultLat = fromMercatorY(ay + (by - ay) * t);
|
||||
|
||||
return new maplibregl.LngLat(resultLng, resultLat);
|
||||
};
|
||||
@@ -125,31 +131,37 @@ export function snapToNearestGeometryDetailed(
|
||||
}
|
||||
};
|
||||
|
||||
const processLineString = (line: number[][], featureId: string | number | undefined) => {
|
||||
const processLineString = (line: number[][], featureId: string | number | undefined, forceClosed = false) => {
|
||||
if (!line || line.length < 2) return;
|
||||
const lineCoords = line.map(c => toCoordinate(c)).filter((c): c is Coordinate => c !== null);
|
||||
for (let i = 0; i < lineCoords.length - 1; i++) {
|
||||
const start = lineCoords[i];
|
||||
const end = lineCoords[i + 1];
|
||||
const parsedCoords = line.map(c => toCoordinate(c)).filter((c): c is Coordinate => c !== null);
|
||||
const treatAsClosed = forceClosed || isClosedRing(parsedCoords);
|
||||
const lineCoords = treatAsClosed ? removeClosingCoordinate(parsedCoords) : parsedCoords;
|
||||
if (lineCoords.length < 2) return;
|
||||
|
||||
processVertex(start, featureId, lineCoords, i);
|
||||
if (i === lineCoords.length - 2) {
|
||||
processVertex(end, featureId, lineCoords, i + 1);
|
||||
}
|
||||
const ringForSnap = treatAsClosed ? closeRing(lineCoords) : lineCoords;
|
||||
for (let i = 0; i < lineCoords.length; i++) {
|
||||
processVertex(lineCoords[i], featureId, ringForSnap, i);
|
||||
}
|
||||
|
||||
const segmentCount = treatAsClosed ? lineCoords.length : lineCoords.length - 1;
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const start = lineCoords[i];
|
||||
const end = lineCoords[(i + 1) % lineCoords.length];
|
||||
|
||||
const p1LngLat = new maplibregl.LngLat(start[0], start[1]);
|
||||
const p2LngLat = new maplibregl.LngLat(end[0], end[1]);
|
||||
const p1 = map.project(p1LngLat);
|
||||
const p2 = map.project(p2LngLat);
|
||||
|
||||
const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
|
||||
const closestT = getClosestTOnSegment(pointPx, p1, p2);
|
||||
const closestPx = getPointOnSegment(p1, p2, closestT);
|
||||
const distSq = getDistSq(pointPx, closestPx);
|
||||
|
||||
if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
|
||||
nearestEdgeDist = distSq;
|
||||
nearestEdgeLngLat = getClosestPointOnLngLatSegment(lngLat, start, end);
|
||||
nearestEdgeLngLat = getPointOnLngLatSegment(start, end, closestT);
|
||||
nearestEdgeFeatureId = featureId;
|
||||
nearestEdgeRing = lineCoords;
|
||||
nearestEdgeRing = ringForSnap;
|
||||
nearestEdgeIdx = i;
|
||||
}
|
||||
}
|
||||
@@ -194,10 +206,10 @@ export function snapToNearestGeometryDetailed(
|
||||
|
||||
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
|
||||
if (type === "Polygon") {
|
||||
for (const ring of asCoordinateMatrix(coords)) processLineString(ring, fId);
|
||||
for (const ring of asCoordinateMatrix(coords)) processLineString(ring, fId, true);
|
||||
} else if (type === "MultiPolygon") {
|
||||
for (const poly of asCoordinateTensor(coords)) {
|
||||
for (const ring of poly) processLineString(ring, fId);
|
||||
for (const ring of poly) processLineString(ring, fId, true);
|
||||
}
|
||||
} else if (type === "LineString") {
|
||||
processLineString(asCoordinateArray(coords), fId);
|
||||
@@ -234,7 +246,7 @@ export function snapToNearestGeometryDetailed(
|
||||
}
|
||||
|
||||
function getSnapLayerIds(map: maplibregl.Map): string[] {
|
||||
const systemGeometrySources = new Set(["countries", "places", PATH_ARROW_SOURCE_ID]);
|
||||
const systemGeometrySources = new Set(["countries", "places"]);
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers?.length) return [];
|
||||
|
||||
@@ -284,45 +296,50 @@ export function tracePathBetweenPoints(
|
||||
startIdx: number,
|
||||
endIdx: number
|
||||
): [number, number][] {
|
||||
const n = ring.length;
|
||||
if (startIdx < 0 || startIdx >= n || endIdx < 0 || endIdx >= n) {
|
||||
const isClosed = isClosedRing(ring);
|
||||
const workingRing = (isClosed ? ring.slice(0, -1) : ring) as [number, number][];
|
||||
const n = workingRing.length;
|
||||
const normalizedStartIdx = isClosed && startIdx === ring.length - 1 ? 0 : startIdx;
|
||||
const normalizedEndIdx = isClosed && endIdx === ring.length - 1 ? 0 : endIdx;
|
||||
|
||||
if (normalizedStartIdx < 0 || normalizedStartIdx >= n || normalizedEndIdx < 0 || normalizedEndIdx >= n) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isClosed = n > 2 &&
|
||||
Math.abs(ring[0][0] - ring[n - 1][0]) < 1e-9 &&
|
||||
Math.abs(ring[0][1] - ring[n - 1][1]) < 1e-9;
|
||||
if (normalizedStartIdx === normalizedEndIdx) {
|
||||
return [workingRing[normalizedStartIdx]];
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
// Case LineString
|
||||
if (startIdx <= endIdx) {
|
||||
return ring.slice(startIdx, endIdx + 1);
|
||||
if (normalizedStartIdx <= normalizedEndIdx) {
|
||||
return workingRing.slice(normalizedStartIdx, normalizedEndIdx + 1);
|
||||
} else {
|
||||
return ring.slice(endIdx, startIdx + 1).reverse();
|
||||
return workingRing.slice(normalizedEndIdx, normalizedStartIdx + 1).reverse();
|
||||
}
|
||||
}
|
||||
|
||||
// Case Closed Polygon
|
||||
// Path 1: Forward
|
||||
const path1: [number, number][] = [];
|
||||
let idx = startIdx;
|
||||
while (idx !== endIdx) {
|
||||
path1.push(ring[idx]);
|
||||
let idx = normalizedStartIdx;
|
||||
while (idx !== normalizedEndIdx) {
|
||||
path1.push(workingRing[idx]);
|
||||
idx = (idx + 1) % n;
|
||||
}
|
||||
path1.push(ring[endIdx]);
|
||||
path1.push(workingRing[normalizedEndIdx]);
|
||||
|
||||
// Path 2: Backward
|
||||
const path2: [number, number][] = [];
|
||||
idx = startIdx;
|
||||
while (idx !== endIdx) {
|
||||
path2.push(ring[idx]);
|
||||
idx = normalizedStartIdx;
|
||||
while (idx !== normalizedEndIdx) {
|
||||
path2.push(workingRing[idx]);
|
||||
idx = (idx - 1 + n) % n;
|
||||
}
|
||||
path2.push(ring[endIdx]);
|
||||
path2.push(workingRing[normalizedEndIdx]);
|
||||
|
||||
const poly1 = [...path1, ring[startIdx]];
|
||||
const poly2 = [...path2, ring[startIdx]];
|
||||
const poly1 = [...path1, workingRing[normalizedStartIdx]];
|
||||
const poly2 = [...path2, workingRing[normalizedStartIdx]];
|
||||
|
||||
const area1 = getArea(poly1);
|
||||
const area2 = getArea(poly2);
|
||||
@@ -335,65 +352,131 @@ export function getRingWithSnaps(
|
||||
snap1: { type: "vertex" | "edge"; vertexIdx?: number; edgeIdx?: number; lngLat: { lng: number; lat: number } },
|
||||
snap2: { type: "vertex" | "edge"; vertexIdx?: number; edgeIdx?: number; lngLat: { lng: number; lat: number } }
|
||||
): { ring: Coordinate[]; idx1: number; idx2: number } {
|
||||
let tempRing = [...ring];
|
||||
|
||||
const coord1: Coordinate = [snap1.lngLat.lng, snap1.lngLat.lat];
|
||||
const coord2: Coordinate = [snap2.lngLat.lng, snap2.lngLat.lat];
|
||||
const closed = isClosedRing(ring);
|
||||
const sourceRing = removeClosingCoordinate(ring);
|
||||
const insertionGroups = new Map<number, Array<{ coord: Coordinate; t: number; owners: Set<1 | 2> }>>();
|
||||
|
||||
let idx1 = -1;
|
||||
let idx2 = -1;
|
||||
type NormalizedSnap =
|
||||
| { type: "vertex"; vertexIdx: number }
|
||||
| { type: "edge"; edgeIdx: number; coord: Coordinate; owner: 1 | 2; t: number };
|
||||
|
||||
if (snap1.type === "vertex" && snap2.type === "vertex") {
|
||||
idx1 = snap1.vertexIdx!;
|
||||
idx2 = snap2.vertexIdx!;
|
||||
} else if (snap1.type === "vertex" && snap2.type === "edge") {
|
||||
idx1 = snap1.vertexIdx!;
|
||||
const eIdx2 = snap2.edgeIdx!;
|
||||
tempRing.splice(eIdx2 + 1, 0, coord2);
|
||||
idx2 = eIdx2 + 1;
|
||||
if (idx1 > eIdx2) {
|
||||
idx1 += 1;
|
||||
const normalizeSnap = (
|
||||
snap: typeof snap1,
|
||||
owner: 1 | 2
|
||||
): NormalizedSnap | null => {
|
||||
const coord: Coordinate = [snap.lngLat.lng, snap.lngLat.lat];
|
||||
if (snap.type === "vertex") {
|
||||
const vertexIdx = normalizeVertexIndex(snap.vertexIdx, sourceRing.length, closed);
|
||||
return vertexIdx === null ? null : { type: "vertex", vertexIdx };
|
||||
}
|
||||
} else if (snap1.type === "edge" && snap2.type === "vertex") {
|
||||
idx2 = snap2.vertexIdx!;
|
||||
const eIdx1 = snap1.edgeIdx!;
|
||||
tempRing.splice(eIdx1 + 1, 0, coord1);
|
||||
idx1 = eIdx1 + 1;
|
||||
if (idx2 > eIdx1) {
|
||||
idx2 += 1;
|
||||
}
|
||||
} else {
|
||||
const eIdx1 = snap1.edgeIdx!;
|
||||
const eIdx2 = snap2.edgeIdx!;
|
||||
|
||||
if (eIdx1 < eIdx2) {
|
||||
tempRing.splice(eIdx2 + 1, 0, coord2);
|
||||
tempRing.splice(eIdx1 + 1, 0, coord1);
|
||||
idx1 = eIdx1 + 1;
|
||||
idx2 = eIdx2 + 2;
|
||||
} else if (eIdx1 > eIdx2) {
|
||||
tempRing.splice(eIdx1 + 1, 0, coord1);
|
||||
tempRing.splice(eIdx2 + 1, 0, coord2);
|
||||
idx1 = eIdx1 + 2;
|
||||
idx2 = eIdx2 + 1;
|
||||
const edgeIdx = snap.edgeIdx;
|
||||
if (edgeIdx === undefined || edgeIdx < 0 || edgeIdx >= sourceRing.length) return null;
|
||||
if (!closed && edgeIdx >= sourceRing.length - 1) return null;
|
||||
|
||||
const startIdx = edgeIdx;
|
||||
const endIdx = (edgeIdx + 1) % sourceRing.length;
|
||||
const start = sourceRing[startIdx];
|
||||
const end = sourceRing[endIdx];
|
||||
if (coordinatesAlmostEqual(coord, start)) return { type: "vertex", vertexIdx: startIdx };
|
||||
if (coordinatesAlmostEqual(coord, end)) return { type: "vertex", vertexIdx: endIdx };
|
||||
|
||||
return {
|
||||
type: "edge",
|
||||
edgeIdx,
|
||||
coord,
|
||||
owner,
|
||||
t: segmentProgress(coord, start, end),
|
||||
};
|
||||
};
|
||||
|
||||
const normalized1 = normalizeSnap(snap1, 1);
|
||||
const normalized2 = normalizeSnap(snap2, 2);
|
||||
|
||||
for (const normalized of [normalized1, normalized2]) {
|
||||
if (!normalized || normalized.type !== "edge") continue;
|
||||
const group = insertionGroups.get(normalized.edgeIdx) || [];
|
||||
const existing = group.find((item) => coordinatesAlmostEqual(item.coord, normalized.coord));
|
||||
if (existing) {
|
||||
existing.owners.add(normalized.owner);
|
||||
} else {
|
||||
const segStart = ring[eIdx1];
|
||||
const dist1 = Math.hypot(coord1[0] - segStart[0], coord1[1] - segStart[1]);
|
||||
const dist2 = Math.hypot(coord2[0] - segStart[0], coord2[1] - segStart[1]);
|
||||
|
||||
if (dist1 <= dist2) {
|
||||
tempRing.splice(eIdx1 + 1, 0, coord1, coord2);
|
||||
idx1 = eIdx1 + 1;
|
||||
idx2 = eIdx1 + 2;
|
||||
} else {
|
||||
tempRing.splice(eIdx1 + 1, 0, coord2, coord1);
|
||||
idx1 = eIdx1 + 2;
|
||||
idx2 = eIdx1 + 1;
|
||||
}
|
||||
group.push({ coord: normalized.coord, t: normalized.t, owners: new Set([normalized.owner]) });
|
||||
}
|
||||
insertionGroups.set(normalized.edgeIdx, group);
|
||||
}
|
||||
|
||||
return { ring: tempRing, idx1, idx2 };
|
||||
const builtRing: Coordinate[] = [];
|
||||
const vertexIndexMap = new Map<number, number>();
|
||||
const edgeIndexMap = new Map<1 | 2, number>();
|
||||
|
||||
for (let i = 0; i < sourceRing.length; i++) {
|
||||
vertexIndexMap.set(i, builtRing.length);
|
||||
builtRing.push(sourceRing[i]);
|
||||
|
||||
const group = insertionGroups.get(i);
|
||||
if (!group) continue;
|
||||
|
||||
group
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.forEach((item) => {
|
||||
const existingIdx = builtRing.findIndex((coord) => coordinatesAlmostEqual(coord, item.coord));
|
||||
const idx = existingIdx >= 0 ? existingIdx : builtRing.length;
|
||||
if (existingIdx < 0) builtRing.push(item.coord);
|
||||
for (const owner of item.owners) {
|
||||
edgeIndexMap.set(owner, idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
builtRing.push(builtRing[0]);
|
||||
}
|
||||
|
||||
const resolveIndex = (normalized: NormalizedSnap | null, owner: 1 | 2): number => {
|
||||
if (!normalized) return -1;
|
||||
if (normalized.type === "vertex") return vertexIndexMap.get(normalized.vertexIdx) ?? -1;
|
||||
return edgeIndexMap.get(owner) ?? -1;
|
||||
};
|
||||
|
||||
return {
|
||||
ring: builtRing,
|
||||
idx1: resolveIndex(normalized1, 1),
|
||||
idx2: resolveIndex(normalized2, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function coordinatesAlmostEqual(a: Coordinate, b: Coordinate, epsilon = COORDINATE_EPSILON): boolean {
|
||||
return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon;
|
||||
}
|
||||
|
||||
function isClosedRing(ring: Coordinate[]): boolean {
|
||||
return ring.length > 2 && coordinatesAlmostEqual(ring[0], ring[ring.length - 1]);
|
||||
}
|
||||
|
||||
function removeClosingCoordinate(ring: Coordinate[]): Coordinate[] {
|
||||
if (!isClosedRing(ring)) return [...ring];
|
||||
return ring.slice(0, -1);
|
||||
}
|
||||
|
||||
function closeRing(ring: Coordinate[]): Coordinate[] {
|
||||
if (ring.length === 0 || isClosedRing(ring)) return [...ring];
|
||||
return [...ring, ring[0]];
|
||||
}
|
||||
|
||||
function normalizeVertexIndex(idx: number | undefined, uniqueLength: number, closed: boolean): number | null {
|
||||
if (idx === undefined || uniqueLength <= 0) return null;
|
||||
if (idx >= 0 && idx < uniqueLength) return idx;
|
||||
if (closed && idx === uniqueLength) return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
function segmentProgress(coord: Coordinate, start: Coordinate, end: Coordinate): number {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return 0;
|
||||
const t = ((coord[0] - start[0]) * dx + (coord[1] - start[1]) * dy) / lenSq;
|
||||
return Math.max(0, Math.min(1, t));
|
||||
}
|
||||
|
||||
export function getOriginalFeature(
|
||||
@@ -403,59 +486,95 @@ export function getOriginalFeature(
|
||||
): GeoJSON.Feature | null {
|
||||
if (featureId === undefined || featureId === null) return null;
|
||||
|
||||
// 1. Prioritize direct lookup inside the React/Zustand draft ref attached to the map instance.
|
||||
// This contains the exact, unsimplified 64-bit coordinates for all local, baseline, and global features.
|
||||
const renderDraft = (map as any)._renderDraftRef?.current;
|
||||
// 1. Prefer the exact GeoJSON data for the rendered source. This avoids mixing geometries
|
||||
// when different sources/layers reuse the same feature id.
|
||||
const source = map.getSource(sourceId) as SourceWithInternalData | undefined;
|
||||
if (source && source._data) {
|
||||
const found = findFeatureInSourceData(source._data, featureId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// 2. Fallback to the React/Zustand draft ref attached to the map instance.
|
||||
const renderDraft = (map as MapWithRenderDraft)._renderDraftRef?.current;
|
||||
if (renderDraft && Array.isArray(renderDraft.features)) {
|
||||
const found = renderDraft.features.find((f: any) => {
|
||||
const found = renderDraft.features.find((f) => {
|
||||
const id = f.properties?.id ?? f.id;
|
||||
return id !== undefined && String(id) === String(featureId);
|
||||
});
|
||||
if (found) {
|
||||
console.log(`[DEBUG] getOriginalFeature: found featureId=${featureId} in map._renderDraftRef`);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to MapLibre's GeoJSONSource internal cache.
|
||||
const source = map.getSource(sourceId) as any;
|
||||
if (!source || !source._data) {
|
||||
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId} source/data not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = source._data;
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFeatureInSourceData(
|
||||
data: unknown,
|
||||
featureId: string | number
|
||||
): GeoJSON.Feature | null {
|
||||
// MapLibre v5 updateable Map lookup
|
||||
if (data.updateable instanceof Map) {
|
||||
const found = data.updateable.get(featureId) || data.updateable.get(String(featureId)) || data.updateable.get(Number(featureId));
|
||||
const updateable = getObjectProperty(data, "updateable");
|
||||
if (updateable instanceof Map) {
|
||||
const featureMap = updateable as Map<string | number, GeoJSON.Feature>;
|
||||
const found = featureMap.get(featureId) || featureMap.get(String(featureId)) || featureMap.get(Number(featureId));
|
||||
if (found) {
|
||||
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, found in updateable Map`);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve GeoJSON object (MapLibre v5 stores geojson under data.geojson)
|
||||
const geojson = data.geojson || data;
|
||||
const geojson = getObjectProperty(data, "geojson") || data;
|
||||
|
||||
if (typeof geojson === "object" && geojson !== null) {
|
||||
if (geojson.type === "FeatureCollection" && Array.isArray(geojson.features)) {
|
||||
const found = geojson.features.find((f: any) => {
|
||||
const id = f.properties?.id ?? f.id;
|
||||
return id !== undefined && String(id) === String(featureId);
|
||||
});
|
||||
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, found in geojson collection=${!!found}`);
|
||||
return found || null;
|
||||
} else if (geojson.type === "Feature") {
|
||||
const id = geojson.properties?.id ?? geojson.id;
|
||||
const matches = id !== undefined && String(id) === String(featureId);
|
||||
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, matched_single=${matches}`);
|
||||
if (matches) {
|
||||
return geojson;
|
||||
}
|
||||
if (isFeatureCollection(geojson)) {
|
||||
const found = geojson.features.find((f: GeoJSON.Feature) => {
|
||||
const id = f.properties?.id ?? f.id;
|
||||
return id !== undefined && String(id) === String(featureId);
|
||||
});
|
||||
return found || null;
|
||||
}
|
||||
|
||||
if (isFeature(geojson)) {
|
||||
const id = geojson.properties?.id ?? geojson.id;
|
||||
const matches = id !== undefined && String(id) === String(featureId);
|
||||
if (matches) {
|
||||
return geojson;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, data format not recognized`, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
type MapWithRenderDraft = maplibregl.Map & {
|
||||
_renderDraftRef?: {
|
||||
current?: {
|
||||
features?: GeoJSON.Feature[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type SourceWithInternalData = maplibregl.Source & {
|
||||
_data?: unknown;
|
||||
};
|
||||
|
||||
function getObjectProperty(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
function isFeatureCollection(value: unknown): value is GeoJSON.FeatureCollection {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { type?: unknown }).type === "FeatureCollection" &&
|
||||
Array.isArray((value as { features?: unknown }).features)
|
||||
);
|
||||
}
|
||||
|
||||
function isFeature(value: unknown): value is GeoJSON.Feature {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { type?: unknown }).type === "Feature"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { LayerSpecification } from "maplibre-gl";
|
||||
import { ExpressionSpecification, FilterSpecification, LayerSpecification } from "maplibre-gl";
|
||||
import { MAP_EMPHASIS_TEXT_FONT_STACK } from "../shared/textFonts";
|
||||
|
||||
const TYPE_MATCH_EXPR: any = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
|
||||
const POINT_GEOMETRY_FILTER: any = [
|
||||
const TYPE_MATCH_EXPR = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""] as unknown as ExpressionSpecification;
|
||||
const POINT_GEOMETRY_FILTER = [
|
||||
"any",
|
||||
["==", ["geometry-type"], "Point"],
|
||||
["==", ["geometry-type"], "MultiPoint"],
|
||||
];
|
||||
const SELECTED_EXPR: any = ["boolean", ["feature-state", "selected"], false];
|
||||
] as unknown as FilterSpecification;
|
||||
const SELECTED_EXPR = ["boolean", ["feature-state", "selected"], false] as unknown as ExpressionSpecification;
|
||||
const SELECTED_COLOR = "#22c55e";
|
||||
|
||||
export function getLocationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||
@@ -15,7 +15,7 @@ export function getLocationLayers(sourceId: string, pathArrowSourceId?: string,
|
||||
void pathArrowSourceId;
|
||||
|
||||
const typeId = "location";
|
||||
const filter: any = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
||||
const filter = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]] as unknown as FilterSpecification;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { LayerSpecification } from "maplibre-gl";
|
||||
import { ExpressionSpecification, FilterSpecification, LayerSpecification } from "maplibre-gl";
|
||||
import { MAP_EMPHASIS_TEXT_FONT_STACK } from "../shared/textFonts";
|
||||
|
||||
const TYPE_MATCH_EXPR: any = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
|
||||
const POINT_GEOMETRY_FILTER: any = [
|
||||
const TYPE_MATCH_EXPR = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""] as unknown as ExpressionSpecification;
|
||||
const POINT_GEOMETRY_FILTER = [
|
||||
"any",
|
||||
["==", ["geometry-type"], "Point"],
|
||||
["==", ["geometry-type"], "MultiPoint"],
|
||||
];
|
||||
] as unknown as FilterSpecification;
|
||||
|
||||
const SELECTED_EXPR: any = ["boolean", ["feature-state", "selected"], false];
|
||||
const SELECTED_EXPR = ["boolean", ["feature-state", "selected"], false] as unknown as ExpressionSpecification;
|
||||
const SELECTED_COLOR = "#22c55e";
|
||||
|
||||
export function getRegionLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||
@@ -16,7 +16,7 @@ export function getRegionLayers(sourceId: string, pathArrowSourceId?: string, po
|
||||
void pathArrowSourceId;
|
||||
|
||||
const typeId = "region";
|
||||
const filter: any = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
||||
const filter = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]] as unknown as FilterSpecification;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -14,13 +14,13 @@ export const POINT_GEOTYPE_IDS = [
|
||||
export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
|
||||
|
||||
export const POINT_GEOTYPE_ICON_PATHS: Partial<Record<PointGeotypeId, string>> = {
|
||||
person_event: "/images/mapIcon/point/flag.png",
|
||||
temple: "/images/mapIcon/point/temple.png",
|
||||
capital: "/images/mapIcon/point/capital.png",
|
||||
city: "/images/mapIcon/point/city.png",
|
||||
fortification: "/images/mapIcon/point/castle.png",
|
||||
ruin: "/images/mapIcon/point/ruin.png",
|
||||
port: "/images/mapIcon/point/port.png",
|
||||
person_event: "/images/mapIcon/point/flag.webp",
|
||||
temple: "/images/mapIcon/point/temple.webp",
|
||||
capital: "/images/mapIcon/point/capital.webp",
|
||||
city: "/images/mapIcon/point/city.webp",
|
||||
fortification: "/images/mapIcon/point/castle.webp",
|
||||
ruin: "/images/mapIcon/point/ruin.webp",
|
||||
port: "/images/mapIcon/point/port.webp",
|
||||
};
|
||||
|
||||
|
||||
@@ -474,4 +474,3 @@ function drawAnchorGlyph(ctx: CanvasRenderingContext2D) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import type {
|
||||
ReplayAction,
|
||||
DialogState,
|
||||
GeoFunctionName,
|
||||
MapFunctionName,
|
||||
NarrativeFunctionName,
|
||||
UIOptionName,
|
||||
} from "@/uhm/types/projects";
|
||||
import { mapActions } from "./mapActions";
|
||||
import { uiActions } from "./uiActions";
|
||||
import { narrativeActions } from "./narrativeActions";
|
||||
import type { ReplayMapEffects } from "./replayMapEffects";
|
||||
|
||||
/**
|
||||
* Interface định nghĩa các controller cần thiết để thực thi Replay.
|
||||
@@ -15,7 +20,7 @@ import { narrativeActions } from "./narrativeActions";
|
||||
export interface ReplayControllers {
|
||||
map: maplibregl.Map | null;
|
||||
draft: FeatureCollection;
|
||||
effects: any; // Type helper for ReplayMapEffects to avoid circular dependency
|
||||
effects: ReplayMapEffects;
|
||||
|
||||
// UI Setters
|
||||
setTimelineVisible: (v: boolean) => void;
|
||||
@@ -45,7 +50,7 @@ export interface ReplayControllers {
|
||||
*/
|
||||
export const dispatchReplayAction = (
|
||||
controllers: ReplayControllers,
|
||||
rawAction: ReplayAction<any> | { function_name: string; params: unknown[] }
|
||||
rawAction: ReplayAction<ReplayFunctionName> | { function_name: string; params: unknown[] }
|
||||
) => {
|
||||
const action = normalizeSingleAction(rawAction);
|
||||
if (!action) return;
|
||||
@@ -192,16 +197,25 @@ export const dispatchReplayAction = (
|
||||
* Lớp tương thích ngược (Backward Compatibility)
|
||||
* Chuẩn hóa các action cũ thành 16 action chính thức.
|
||||
*/
|
||||
function normalizeSingleAction(action: any): ReplayAction<any> | null {
|
||||
type ReplayFunctionName = UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName;
|
||||
type ReplayActionLike = {
|
||||
function_name?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSingleAction(action: unknown): ReplayAction<ReplayFunctionName> | null {
|
||||
if (!action || typeof action !== "object") return null;
|
||||
|
||||
let { function_name, params } = action;
|
||||
if (!Array.isArray(params)) {
|
||||
params = [];
|
||||
}
|
||||
let { function_name } = action as ReplayActionLike;
|
||||
let params: unknown[] = Array.isArray((action as ReplayActionLike).params)
|
||||
? (action as { params: unknown[] }).params
|
||||
: [];
|
||||
if (typeof function_name !== "string") return null;
|
||||
|
||||
if (function_name === "UI") {
|
||||
function_name = params[0];
|
||||
const legacyFunctionName = params[0];
|
||||
if (typeof legacyFunctionName !== "string") return null;
|
||||
function_name = legacyFunctionName;
|
||||
params = params.slice(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export function useReplayPreview({
|
||||
}: UseReplayPreviewOptions) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const isPlayingRef = useRef(false);
|
||||
isPlayingRef.current = isPlaying;
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||
const dialogRef = useRef<DialogState | null>(null);
|
||||
const setDialogWithRef = useCallback((d: DialogState | null) => {
|
||||
@@ -105,6 +104,10 @@ export function useReplayPreview({
|
||||
|
||||
const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]);
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
playbackSpeedRef.current = playbackSpeed;
|
||||
}, [playbackSpeed]);
|
||||
@@ -164,6 +167,7 @@ export function useReplayPreview({
|
||||
}, []);
|
||||
|
||||
const restorePreviewState = useCallback(() => {
|
||||
isPlayingRef.current = false;
|
||||
setIsPlaying(false);
|
||||
setActiveCursor({ stageId: null, stepIndex: null });
|
||||
setActiveStepNumber(null);
|
||||
@@ -215,11 +219,13 @@ export function useReplayPreview({
|
||||
|
||||
useEffect(() => {
|
||||
runIdRef.current += 1;
|
||||
restorePreviewState();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
restorePreviewState();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [replay?.id, restorePreviewState]);
|
||||
|
||||
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
|
||||
controllersRef.current = {
|
||||
const controllers = useMemo<Parameters<typeof dispatchReplayAction>[0]>(() => ({
|
||||
map: getMapInstance(),
|
||||
draft,
|
||||
effects,
|
||||
@@ -284,10 +290,20 @@ export function useReplayPreview({
|
||||
},
|
||||
setDialog: setDialogWithRef,
|
||||
getDialog: () => dialogRef.current,
|
||||
};
|
||||
}), [
|
||||
addToast,
|
||||
draft,
|
||||
effects,
|
||||
getMapInstance,
|
||||
setDialogWithRef,
|
||||
]);
|
||||
|
||||
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
|
||||
useEffect(() => {
|
||||
controllersRef.current = controllers;
|
||||
}, [controllers]);
|
||||
|
||||
const playFromIndex = useCallback(async (startIndex: number) => {
|
||||
console.log("playFromIndex starting at:", startIndex, "flatSteps count:", flatSteps.length);
|
||||
if (!flatSteps.length) return;
|
||||
|
||||
const map = getMapInstance();
|
||||
@@ -321,11 +337,11 @@ export function useReplayPreview({
|
||||
|
||||
const runId = runIdRef.current + 1;
|
||||
runIdRef.current = runId;
|
||||
isPlayingRef.current = true;
|
||||
setIsPlaying(true);
|
||||
|
||||
for (let index = safeStartIndex; index < flatSteps.length; index += 1) {
|
||||
if (runIdRef.current !== runId) {
|
||||
console.log("playFromIndex loop aborted because runId changed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -339,7 +355,6 @@ export function useReplayPreview({
|
||||
|
||||
const controllers = controllersRef.current;
|
||||
if (!controllers) {
|
||||
console.warn("playFromIndex aborted: controllersRef.current is null!");
|
||||
return;
|
||||
}
|
||||
controllers.map = getMapInstance();
|
||||
@@ -388,7 +403,6 @@ export function useReplayPreview({
|
||||
|
||||
const playFromSelection = useCallback(() => {
|
||||
const selectedIndex = findReplayStepIndex(flatSteps, selectedStageIdRef.current, selectedStepIndexRef.current);
|
||||
console.log("playFromSelection called: selectedIndex =", selectedIndex, "selectedStageId =", selectedStageIdRef.current, "selectedStepIndex =", selectedStepIndexRef.current);
|
||||
void playFromIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [flatSteps, playFromIndex]);
|
||||
|
||||
@@ -428,6 +442,8 @@ export function useReplayPreview({
|
||||
};
|
||||
}
|
||||
|
||||
export type ReplayPreviewController = ReturnType<typeof useReplayPreview>;
|
||||
|
||||
function flattenReplaySteps(replay: BattleReplay | null): FlattenedReplayStep[] {
|
||||
if (!replay) return [];
|
||||
return replay.detail.flatMap((stage) =>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||
|
||||
export function formatEntityHoverTitle(
|
||||
name: string,
|
||||
timeStart: unknown,
|
||||
timeEnd: unknown
|
||||
): string {
|
||||
const range = formatEntityTimeRange(timeStart, timeEnd);
|
||||
return range ? `${name} (${range})` : name;
|
||||
}
|
||||
|
||||
export function formatEntityTimeRange(timeStart: unknown, timeEnd: unknown): string {
|
||||
const start = normalizeTimelineYearValue(timeStart);
|
||||
const end = normalizeTimelineYearValue(timeEnd);
|
||||
if (start == null && end == null) return "";
|
||||
if (start != null && end != null && start === end) return formatTimelineYear(start);
|
||||
return `${start == null ? "?" : formatTimelineYear(start)}-${end == null ? "?" : formatTimelineYear(end)}`;
|
||||
}
|
||||
|
||||
export function isTimelineYearWithinEntityTimeRange(
|
||||
timelineYear: unknown,
|
||||
timeStart: unknown,
|
||||
timeEnd: unknown
|
||||
): boolean {
|
||||
const year = normalizeTimelineYearValue(timelineYear);
|
||||
const start = normalizeTimelineYearValue(timeStart);
|
||||
const end = normalizeTimelineYearValue(timeEnd);
|
||||
if (year == null || (start == null && end == null)) return false;
|
||||
if (start != null && year < start) return false;
|
||||
if (end != null && year > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatTimelineYear(year: number): string {
|
||||
if (year < 0) return `${Math.abs(year)} TCN`;
|
||||
return String(year);
|
||||
}
|
||||
@@ -23,4 +23,5 @@ export type GeometriesBBoxQuery = {
|
||||
// New API: time_range (0-30) to expand the timeline query window.
|
||||
timeRange?: number;
|
||||
entity_id?: string;
|
||||
hasBound?: boolean;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export type FeatureProperties = {
|
||||
geometry_preset?: GeometryPreset | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
replay_ids?: string[];
|
||||
bound_with?: string | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
@@ -33,6 +34,7 @@ export type FeatureProperties = {
|
||||
point_label?: string | null;
|
||||
line_label?: string | null;
|
||||
polygon_label?: string | null;
|
||||
entity_color?: string;
|
||||
};
|
||||
|
||||
export type EntityLabelCandidate = {
|
||||
|
||||
Reference in New Issue
Block a user