basic Uer UI
Build and Release / release (push) Successful in 1m30s

This commit is contained in:
taDuc
2026-06-06 15:36:15 +07:00
parent 61949e7149
commit 820e0b5216
65 changed files with 3460 additions and 1322 deletions
+14 -49
View File
@@ -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
View File
@@ -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;
};
+4
View File
@@ -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}`);
}
+23
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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(() => {
+3
View File
@@ -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>
+150 -17
View File
@@ -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) {
+4 -4
View File
@@ -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;
+437 -34
View File
@@ -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;
+1 -1
View File
@@ -93,7 +93,7 @@ export function useMapInstance() {
}
};
let throttleTimeout: any = null;
let throttleTimeout: ReturnType<typeof setTimeout> | null = null;
const syncZoomLevelImmediate = () => {
if (isZoomSliderDraggingRef.current) return;
+13 -4
View File
@@ -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] {
+3 -1
View File
@@ -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,
+52 -14
View File
@@ -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>;
};
+153 -36
View File
@@ -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;
+25 -15
View File
@@ -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)) {
-32
View File
@@ -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) {
+37 -58
View File
@@ -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]);
}
+126 -102
View File
@@ -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();
};
+3 -3
View File
@@ -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;
+258 -139
View File
@@ -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"
);
}
+6 -6
View File
@@ -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 [
{
+6 -6
View File
@@ -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 [
{
+7 -8
View File
@@ -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) {
}
}
+22 -8
View File
@@ -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);
}
+25 -9
View File
@@ -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) =>
+37
View File
@@ -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);
}
+1
View File
@@ -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;
};
+2
View File
@@ -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 = {